From 6c1f39005c507078d579dcbc8b500d357b0d851e Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 03:47:56 +0900 Subject: [PATCH] feat: migrate Todo display from message parsing to session.todo() API (#15) - Add getSessionTodos() method to OpenCodeConnection - Add sessionTodos / getSessionTodos message protocol - Handle todo.updated SSE event for real-time updates - Remove latestTodos derivation from useMessages hook - Update TodoHeader and AppContext to use SDK Todo type - Fetch todos on session activation, clear on session deselect - Update scenario tests for new API-based Todo flow --- src/chat-view-provider.ts | 10 +- src/opencode-client.ts | 13 +- webview/App.tsx | 20 ++- webview/__tests__/hooks/useMessages.test.ts | 34 ----- webview/__tests__/scenarios/11-todo.test.tsx | 125 ++++++++++-------- .../molecules/TodoHeader/TodoHeader.tsx | 4 +- webview/contexts/AppContext.tsx | 5 +- webview/hooks/useMessages.ts | 28 ---- webview/vscode-api.ts | 6 +- 9 files changed, 114 insertions(+), 131 deletions(-) diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index fcf7018..3b1fad4 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -11,6 +11,7 @@ import type { Provider, ProviderListResult, Session, + Todo, } from "./opencode-client"; // --- File attachment --- @@ -38,7 +39,8 @@ export type ExtToWebviewMessage = | { type: "toolConfig"; paths: OpenCodePath } | { type: "locale"; vscodeLanguage: string } | { type: "modelUpdated"; model: string; default: Record } - | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] }; + | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } + | { type: "sessionTodos"; sessionId: string; todos: Todo[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -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" }; @@ -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( diff --git a/src/opencode-client.ts b/src/opencode-client.ts index 09fea66..503b433 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -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 = { @@ -246,6 +247,16 @@ export class OpenCodeConnection { }); } + // --- Session Todo API --- + + async getSessionTodos(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.todo({ + path: { id: sessionId }, + }); + return response.data!; + } + // --- Session Diff API --- async getSessionDiff(sessionId: string): Promise { diff --git a/webview/App.tsx b/webview/App.tsx index 38a3fab..ae91edd 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -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"; @@ -32,6 +32,7 @@ export function App() { // Extension Host → Webview メッセージでのみ更新される単純なステート const [openEditors, setOpenEditors] = useState([]); const [workspaceFiles, setWorkspaceFiles] = useState([]); + const [todos, setTodos] = useState([]); const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; @@ -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, @@ -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": @@ -140,6 +148,12 @@ export function App() { } break; } + case "sessionTodos": { + if (data.sessionId === session.activeSession?.id) { + setTodos(data.todos); + } + break; + } } }; window.addEventListener("message", handler); @@ -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, @@ -325,7 +339,7 @@ export function App() { onEditAndResend={handleEditAndResend} onRevertToCheckpoint={handleRevertToCheckpoint} /> - {msg.latestTodos.length > 0 && } + {todos.length > 0 && } {fileChanges.diffs.length > 0 && ( )} diff --git a/webview/__tests__/hooks/useMessages.test.ts b/webview/__tests__/hooks/useMessages.test.ts index ab8cf52..cab04cb 100644 --- a/webview/__tests__/hooks/useMessages.test.ts +++ b/webview/__tests__/hooks/useMessages.test.ts @@ -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()); @@ -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" }]); - }); - }); }); diff --git a/webview/__tests__/scenarios/11-todo.test.tsx b/webview/__tests__/scenarios/11-todo.test.tsx index fc288cc..a25f71e 100644 --- a/webview/__tests__/scenarios/11-todo.test.tsx +++ b/webview/__tests__/scenarios/11-todo.test.tsx @@ -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" }, ]); }); @@ -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(); @@ -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(); @@ -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(); + }); }); diff --git a/webview/components/molecules/TodoHeader/TodoHeader.tsx b/webview/components/molecules/TodoHeader/TodoHeader.tsx index 1ae6ea5..5cdb09e 100644 --- a/webview/components/molecules/TodoHeader/TodoHeader.tsx +++ b/webview/components/molecules/TodoHeader/TodoHeader.tsx @@ -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) { diff --git a/webview/contexts/AppContext.tsx b/webview/contexts/AppContext.tsx index 95611cd..a1ba74e 100644 --- a/webview/contexts/AppContext.tsx +++ b/webview/contexts/AppContext.tsx @@ -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 = { @@ -19,7 +18,7 @@ export type AppContextValue = { // Messages messages: MessageWithParts[]; inputTokens: number; - latestTodos: TodoItem[]; + latestTodos: Todo[]; prefillText: string; onPrefillConsumed: () => void; diff --git a/webview/hooks/useMessages.ts b/webview/hooks/useMessages.ts index a0b4eb3..5879a39 100644 --- a/webview/hooks/useMessages.ts +++ b/webview/hooks/useMessages.ts @@ -1,7 +1,5 @@ import type { Event, Message, Part } from "@opencode-ai/sdk"; import { useCallback, useMemo, useRef, useState } from "react"; -import type { TodoItem } from "../utils/todo"; -import { parseTodos } from "../utils/todo"; export type MessageWithParts = { info: Message; parts: Part[] }; @@ -75,31 +73,6 @@ export function useMessages() { return total; }, [messages]); - // メッセージから最新の ToDo リストを導出(todowrite/todoread ツールの最新の出力) - const latestTodos = useMemo(() => { - for (let mi = messages.length - 1; mi >= 0; mi--) { - const parts = messages[mi].parts; - for (let pi = parts.length - 1; pi >= 0; pi--) { - const p = parts[pi]; - if (p.type !== "tool") continue; - if (p.tool !== "todowrite" && p.tool !== "todoread") continue; - const st = p.state; - if (st.status === "completed" && st.output) { - const parsed = parseTodos(st.output); - if (parsed) return parsed; - } - if (st.status !== "pending") { - const input = st.input as Record | undefined; - if (input) { - const parsed = parseTodos(input.todos ?? input); - if (parsed) return parsed; - } - } - } - } - return []; - }, [messages]); - const consumePrefill = useCallback(() => { setPrefillText(""); }, []); @@ -138,7 +111,6 @@ export function useMessages() { prefillText, setPrefillText, inputTokens, - latestTodos, consumePrefill, handleMessageEvent, markPendingShell, diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index fd32caf..8dd1b84 100644 --- a/webview/vscode-api.ts +++ b/webview/vscode-api.ts @@ -3,7 +3,7 @@ * Extension Host 側の chat-view-provider.ts で定義したプロトコルに対応する。 */ -import type { Event, FileDiff, Message, Part, Provider, Session } from "@opencode-ai/sdk"; +import type { Event, FileDiff, Message, Part, Provider, Session, Todo } from "@opencode-ai/sdk"; // --- File attachment --- export type FileAttachment = { @@ -66,7 +66,8 @@ export type ExtToWebviewMessage = | { type: "toolConfig"; paths: { home: string; config: string; state: string; directory: string } } | { type: "locale"; vscodeLanguage: string } | { type: "modelUpdated"; model: string; default: Record } - | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] }; + | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } + | { type: "sessionTodos"; sessionId: string; todos: Todo[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -102,6 +103,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" };