Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
Provider,
ProviderListResult,
Session,
Todo,
} from "./opencode-client";

// --- File attachment ---
Expand Down Expand Up @@ -38,7 +39,8 @@ export type ExtToWebviewMessage =
| { type: "toolConfig"; paths: OpenCodePath }
| { type: "locale"; vscodeLanguage: string }
| { type: "modelUpdated"; model: string; default: Record<string, string> }
| { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] };
| { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] }
| { type: "sessionTodos"; sessionId: string; todos: Todo[] };

// --- Webview → Extension Host ---
export type WebviewToExtMessage =
Expand Down Expand Up @@ -74,6 +76,7 @@ export type WebviewToExtMessage =
| { type: "openTerminal" }
| { type: "setModel"; model: string }
| { type: "getSessionDiff"; sessionId: string }
| { type: "getSessionTodos"; sessionId: string }
| { type: "openDiffEditor"; filePath: string; before: string; after: string }
| { type: "ready" };

Expand Down Expand Up @@ -335,6 +338,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
this.postMessage({ type: "sessionDiff", sessionId: message.sessionId, diffs });
break;
}
case "getSessionTodos": {
const todos = await this.connection.getSessionTodos(message.sessionId);
this.postMessage({ type: "sessionTodos", sessionId: message.sessionId, todos });
break;
}
case "openDiffEditor": {
// 仮想ドキュメントを使って VS Code のネイティブ diff エディタを開く
const beforeUri = vscode.Uri.parse(
Expand Down
13 changes: 12 additions & 1 deletion src/opencode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import {
type Part,
type Provider,
type Session,
type Todo,
type ToolListItem,
} from "@opencode-ai/sdk";
import * as vscode from "vscode";

export type { Event, Session, Message, Part, Provider, McpStatus, ToolListItem, Config, OpenCodePath, FileDiff };
export type { Event, Session, Message, Part, Provider, McpStatus, ToolListItem, Config, OpenCodePath, FileDiff, Todo };

// provider.list() が返す生データ型
export type ProviderListResult = {
Expand Down Expand Up @@ -246,6 +247,16 @@ export class OpenCodeConnection {
});
}

// --- Session Todo API ---

async getSessionTodos(sessionId: string): Promise<Todo[]> {
const client = this.requireClient();
const response = await client.session.todo({
path: { id: sessionId },
});
return response.data!;
}

// --- Session Diff API ---

async getSessionDiff(sessionId: string): Promise<FileDiff[]> {
Expand Down
20 changes: 17 additions & 3 deletions webview/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Event } from "@opencode-ai/sdk";
import type { Event, Todo } from "@opencode-ai/sdk";
import { useCallback, useEffect, useState } from "react";
import { EmptyState } from "./components/molecules/EmptyState";
import { FileChangesHeader } from "./components/molecules/FileChangesHeader";
Expand Down Expand Up @@ -32,6 +32,7 @@ export function App() {
// Extension Host → Webview メッセージでのみ更新される単純なステート
const [openEditors, setOpenEditors] = useState<FileAttachment[]>([]);
const [workspaceFiles, setWorkspaceFiles] = useState<FileAttachment[]>([]);
const [todos, setTodos] = useState<Todo[]>([]);
const [openCodePaths, setOpenCodePaths] = useState<{
home: string;
config: string;
Expand Down Expand Up @@ -59,6 +60,11 @@ export function App() {
if (event.type === "file.edited" && session.activeSession) {
postMessage({ type: "getSessionDiff", sessionId: session.activeSession.id });
}

// todo.updated イベント時にアクティブセッションの Todo を更新する
if (event.type === "todo.updated" && event.properties.sessionID === session.activeSession?.id) {
setTodos(event.properties.todos as Todo[]);
}
},
[
session.handleSessionEvent,
Expand Down Expand Up @@ -87,9 +93,11 @@ export function App() {
if (data.session) {
postMessage({ type: "getMessages", sessionId: data.session.id });
postMessage({ type: "getSessionDiff", sessionId: data.session.id });
postMessage({ type: "getSessionTodos", sessionId: data.session.id });
} else {
msg.setMessages([]);
fileChanges.clearDiffs();
setTodos([]);
}
break;
case "event":
Expand Down Expand Up @@ -140,6 +148,12 @@ export function App() {
}
break;
}
case "sessionTodos": {
if (data.sessionId === session.activeSession?.id) {
setTodos(data.todos);
}
break;
}
}
};
window.addEventListener("message", handler);
Expand Down Expand Up @@ -269,7 +283,7 @@ export function App() {
onToggleSessionList: session.toggleSessionList,
messages: msg.messages,
inputTokens: msg.inputTokens,
latestTodos: msg.latestTodos,
latestTodos: todos,
prefillText: msg.prefillText,
onPrefillConsumed: msg.consumePrefill,
providers: prov.providers,
Expand Down Expand Up @@ -325,7 +339,7 @@ export function App() {
onEditAndResend={handleEditAndResend}
onRevertToCheckpoint={handleRevertToCheckpoint}
/>
{msg.latestTodos.length > 0 && <TodoHeader todos={msg.latestTodos} />}
{todos.length > 0 && <TodoHeader todos={todos} />}
{fileChanges.diffs.length > 0 && (
<FileChangesHeader diffs={fileChanges.diffs} onOpenDiffEditor={handleOpenDiffEditor} />
)}
Expand Down
34 changes: 0 additions & 34 deletions webview/__tests__/hooks/useMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ describe("useMessages", () => {
expect(result.current.inputTokens).toBe(0);
});

// latestTodos is empty
it("latestTodos が空配列であること", () => {
const { result } = renderHook(() => useMessages());
expect(result.current.latestTodos).toEqual([]);
});

// prefillText is empty
it("prefillText が空文字であること", () => {
const { result } = renderHook(() => useMessages());
Expand Down Expand Up @@ -238,32 +232,4 @@ describe("useMessages", () => {
expect(result.current.isShellMessage("m1")).toBe(false);
});
});

// latestTodos derivation
context("messages に todowrite ツールの完了出力がある場合", () => {
// parses todos from completed tool output
it("latestTodos にパース結果を返すこと", () => {
const { result } = renderHook(() => useMessages());
const msgs: MessageWithParts[] = [
{
info: { id: "m1" } as any,
parts: [
{
id: "p1",
messageID: "m1",
type: "tool",
tool: "todowrite",
state: {
status: "completed",
output: JSON.stringify([{ content: "task1", status: "done" }]),
input: {},
},
} as any,
],
},
];
act(() => result.current.setMessages(msgs));
expect(result.current.latestTodos).toEqual([{ content: "task1", status: "done" }]);
});
});
});
125 changes: 68 additions & 57 deletions webview/__tests__/scenarios/11-todo.test.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,25 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { createMessage, createSession } from "../factories";
import { createEvent, createSession } from "../factories";
import { renderApp, sendExtMessage } from "../helpers";

/** todowrite ツール出力付きのセッションをセットアップする */
async function setupWithTodos(todos: Array<{ content: string; status: string; priority?: string }>) {
/** session.todo() API 経由で Todo 付きのセッションをセットアップする */
async function setupWithTodos(todos: Array<{ id: string; content: string; status: string; priority: string }>) {
renderApp();
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) });

const msg = createMessage({ id: "m1", sessionID: "s1", role: "assistant" });
const todoPart = {
id: "tp1",
type: "tool" as const,
tool: "todowrite",
messageID: "m1",
time: { created: Date.now(), updated: Date.now() },
state: {
status: "completed",
title: "todowrite",
input: { todos },
output: JSON.stringify(todos),
},
};

await sendExtMessage({
type: "messages",
sessionId: "s1",
messages: [{ info: msg, parts: [todoPart as any] }],
});
await sendExtMessage({ type: "sessionTodos", sessionId: "s1", todos });
}

// Todo
describe("Todo", () => {
// TodoHeader is shown from todowrite output
context("todowrite 出力から TodoHeader を表示した場合", () => {
// TodoHeader is shown from session.todo() API
context("session.todo() API から TodoHeader を表示した場合", () => {
beforeEach(async () => {
await setupWithTodos([
{ content: "First task", status: "completed" },
{ content: "Second task", status: "in-progress" },
{ content: "Third task", status: "pending" },
{ id: "t1", content: "First task", status: "completed", priority: "medium" },
{ id: "t2", content: "Second task", status: "in_progress", priority: "medium" },
{ id: "t3", content: "Third task", status: "pending", priority: "low" },
]);
});

Expand All @@ -58,8 +38,8 @@ describe("Todo", () => {
context("Todo 一覧を展開した場合", () => {
beforeEach(async () => {
await setupWithTodos([
{ content: "Implement feature", status: "completed" },
{ content: "Write tests", status: "in-progress", priority: "high" },
{ id: "t1", content: "Implement feature", status: "completed", priority: "medium" },
{ id: "t2", content: "Write tests", status: "in_progress", priority: "high" },
]);

const user = userEvent.setup();
Expand Down Expand Up @@ -87,37 +67,25 @@ describe("Todo", () => {
renderApp();
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) });

// メッセージなし → TodoHeader なし
// Todo なし → TodoHeader なし
expect(screen.queryByText("To Do")).not.toBeInTheDocument();
});

// Todos are also shown from todoread tool output
it("todoread ツールの出力からも Todo が表示されること", async () => {
// todo.updated SSE event updates TodoHeader in real-time
it("todo.updated SSE イベントで TodoHeader がリアルタイム更新されること", async () => {
renderApp();
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) });

const msg = createMessage({ id: "m1", sessionID: "s1", role: "assistant" });
const todoPart = {
id: "tp1",
type: "tool" as const,
tool: "todoread",
messageID: "m1",
time: { created: Date.now(), updated: Date.now() },
state: {
status: "completed",
title: "todoread",
input: {},
output: JSON.stringify([
{ content: "Read task 1", status: "completed" },
{ content: "Read task 2", status: "pending" },
]),
},
};

// todo.updated イベントで Todo を受信
await sendExtMessage({
type: "messages",
sessionId: "s1",
messages: [{ info: msg, parts: [todoPart as any] }],
type: "event",
event: createEvent("todo.updated", {
sessionID: "s1",
todos: [
{ id: "t1", content: "SSE task 1", status: "completed", priority: "medium" },
{ id: "t2", content: "SSE task 2", status: "pending", priority: "low" },
],
}),
});

expect(screen.getByText("1/2")).toBeInTheDocument();
Expand All @@ -126,10 +94,53 @@ describe("Todo", () => {
// Count matches when all todos are completed
it("全て完了のとき件数が一致すること", async () => {
await setupWithTodos([
{ content: "Task A", status: "completed" },
{ content: "Task B", status: "done" },
{ id: "t1", content: "Task A", status: "completed", priority: "medium" },
{ id: "t2", content: "Task B", status: "completed", priority: "low" },
]);

expect(screen.getByText("2/2")).toBeInTheDocument();
});

// Todos are cleared when switching to a session without todos
it("セッション切替で Todo がクリアされること", async () => {
await setupWithTodos([{ id: "t1", content: "Some task", status: "pending", priority: "medium" }]);
expect(screen.getByText("To Do")).toBeInTheDocument();

// 別のセッション(Todo なし)に切替
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s2" }) });
// 新セッションの sessionTodos 応答(空)でクリアされる
await sendExtMessage({ type: "sessionTodos", sessionId: "s2", todos: [] });

expect(screen.queryByText("To Do")).not.toBeInTheDocument();
});

// todo.updated for a different session is ignored
it("別セッションの todo.updated イベントは無視されること", async () => {
renderApp();
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) });

// 別セッション (s999) の todo.updated
await sendExtMessage({
type: "event",
event: createEvent("todo.updated", {
sessionID: "s999",
todos: [{ id: "t1", content: "Other session task", status: "pending", priority: "medium" }],
}),
});

expect(screen.queryByText("To Do")).not.toBeInTheDocument();
});

// Todos are preserved when activeSession message is re-sent for the same session
it("同じセッションの activeSession 再送で Todo がクリアされないこと", async () => {
await setupWithTodos([{ id: "t1", content: "Persistent task", status: "pending", priority: "medium" }]);
expect(screen.getByText("To Do")).toBeInTheDocument();

// 同じセッション s1 の activeSession が再送される(session.updated 等で発生しうる)
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) });

// Todo はクリアされず維持される
expect(screen.getByText("To Do")).toBeInTheDocument();
expect(screen.getByText("0/1")).toBeInTheDocument();
});
});
4 changes: 2 additions & 2 deletions webview/components/molecules/TodoHeader/TodoHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Todo } from "@opencode-ai/sdk";
import { useState } from "react";
import { useLocale } from "../../../locales";
import type { TodoItem } from "../../../utils/todo";
import { CheckboxIcon, ChevronRightIcon } from "../../atoms/icons";
import type { BadgeVariant } from "../../atoms/StatusItem";
import { StatusItem } from "../../atoms/StatusItem";
import styles from "./TodoHeader.module.css";

type Props = {
todos: TodoItem[];
todos: Todo[];
};

export function TodoHeader({ todos }: Props) {
Expand Down
5 changes: 2 additions & 3 deletions webview/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { FileDiff, Permission, Provider, Session } from "@opencode-ai/sdk";
import type { FileDiff, Permission, Provider, Session, Todo } from "@opencode-ai/sdk";
import { createContext, useContext } from "react";
import type { MessageWithParts } from "../hooks/useMessages";
import type { LocaleSetting } from "../locales";
import type { TodoItem } from "../utils/todo";
import type { AllProvidersData, FileAttachment } from "../vscode-api";

export type AppContextValue = {
Expand All @@ -19,7 +18,7 @@ export type AppContextValue = {
// Messages
messages: MessageWithParts[];
inputTokens: number;
latestTodos: TodoItem[];
latestTodos: Todo[];
prefillText: string;
onPrefillConsumed: () => void;

Expand Down
Loading