From d68c766ce15f3d8ec6f89445e9d110b0f7ae6a19 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:06:49 +0900 Subject: [PATCH 01/15] feat: add getChildSessions API and child session protocol messages --- src/chat-view-provider.ts | 9 ++++++++- src/opencode-client.ts | 10 ++++++++++ webview/vscode-api.ts | 4 +++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index 8048696..fea1b76 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -40,7 +40,8 @@ export type ExtToWebviewMessage = | { type: "locale"; vscodeLanguage: string } | { type: "modelUpdated"; model: string; default: Record } | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } - | { type: "sessionTodos"; sessionId: string; todos: Todo[] }; + | { type: "sessionTodos"; sessionId: string; todos: Todo[] } + | { type: "childSessions"; sessionId: string; children: Session[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -78,6 +79,7 @@ export type WebviewToExtMessage = | { type: "forkSession"; sessionId: string; messageId?: string } | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } + | { type: "getChildSessions"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; @@ -353,6 +355,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "sessionTodos", sessionId: message.sessionId, todos }); break; } + case "getChildSessions": { + const children = await this.connection.getChildSessions(message.sessionId); + this.postMessage({ type: "childSessions", sessionId: message.sessionId, children }); + break; + } case "openDiffEditor": { // 仮想ドキュメントを使って VS Code のネイティブ diff エディタを開く const beforeUri = vscode.Uri.parse( diff --git a/src/opencode-client.ts b/src/opencode-client.ts index 291a5e4..fa29a16 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -256,6 +256,16 @@ export class OpenCodeConnection { }); } + // --- Session Children API --- + + async getChildSessions(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.children({ + path: { id: sessionId }, + }); + return response.data!; + } + // --- Session Todo API --- async getSessionTodos(sessionId: string): Promise { diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index a07f4ff..1017943 100644 --- a/webview/vscode-api.ts +++ b/webview/vscode-api.ts @@ -67,7 +67,8 @@ export type ExtToWebviewMessage = | { type: "locale"; vscodeLanguage: string } | { type: "modelUpdated"; model: string; default: Record } | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } - | { type: "sessionTodos"; sessionId: string; todos: Todo[] }; + | { type: "sessionTodos"; sessionId: string; todos: Todo[] } + | { type: "childSessions"; sessionId: string; children: Session[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -105,6 +106,7 @@ export type WebviewToExtMessage = | { type: "forkSession"; sessionId: string; messageId?: string } | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } + | { type: "getChildSessions"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; From 4bc3d70f024678fec82bceeb9db90b1509d79548 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:06:53 +0900 Subject: [PATCH 02/15] feat: add child session navigation UI with subtask display and back navigation --- webview/App.tsx | 80 +++++++++++++------ webview/components/atoms/icons/icons.tsx | 20 +++++ .../organisms/ChatHeader/ChatHeader.tsx | 25 ++++-- .../organisms/MessageItem/MessageItem.tsx | 12 ++- .../SubtaskPartView.module.css | 52 ++++++++++++ .../SubtaskPartView/SubtaskPartView.tsx | 61 ++++++++++++++ .../organisms/SubtaskPartView/index.ts | 1 + webview/contexts/AppContext.tsx | 5 ++ webview/locales/en.ts | 4 + webview/locales/ja.ts | 4 + 10 files changed, 231 insertions(+), 33 deletions(-) create mode 100644 webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css create mode 100644 webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx create mode 100644 webview/components/organisms/SubtaskPartView/index.ts diff --git a/webview/App.tsx b/webview/App.tsx index d72bd56..3256824 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,4 +1,4 @@ -import type { Event, Todo } from "@opencode-ai/sdk"; +import type { Event, Session, Todo } from "@opencode-ai/sdk"; import { useCallback, useEffect, useState } from "react"; import { EmptyState } from "./components/molecules/EmptyState"; import { FileChangesHeader } from "./components/molecules/FileChangesHeader"; @@ -33,6 +33,7 @@ export function App() { const [openEditors, setOpenEditors] = useState([]); const [workspaceFiles, setWorkspaceFiles] = useState([]); const [todos, setTodos] = useState([]); + const [childSessions, setChildSessions] = useState([]); const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; @@ -94,10 +95,12 @@ export function App() { postMessage({ type: "getMessages", sessionId: data.session.id }); postMessage({ type: "getSessionDiff", sessionId: data.session.id }); postMessage({ type: "getSessionTodos", sessionId: data.session.id }); + postMessage({ type: "getChildSessions", sessionId: data.session.id }); } else { msg.setMessages([]); fileChanges.clearDiffs(); setTodos([]); + setChildSessions([]); } break; case "event": @@ -154,6 +157,12 @@ export function App() { } break; } + case "childSessions": { + if (data.sessionId === session.activeSession?.id) { + setChildSessions(data.children); + } + break; + } } }; window.addEventListener("message", handler); @@ -285,6 +294,23 @@ export function App() { [session.activeSession], ); + // 子セッションにナビゲートする + const handleNavigateToChild = useCallback( + (sessionId: string) => { + session.handleSelectSession(sessionId); + }, + [session.handleSelectSession], + ); + + // 親セッションに戻る + const handleNavigateToParent = useCallback(() => { + if (!session.activeSession?.parentID) return; + session.handleSelectSession(session.activeSession.parentID); + }, [session.activeSession, session.handleSelectSession]); + + // 子セッション閲覧中かの判定 + const isChildSession = !!session.activeSession?.parentID; + const contextValue: AppContextValue = { sessions: session.sessions, activeSession: session.activeSession, @@ -323,6 +349,9 @@ export function App() { onOpenTerminal: handleOpenTerminal, localeSetting: locale.localeSetting, onLocaleSettingChange: locale.handleLocaleSettingChange, + childSessions, + onNavigateToChild: handleNavigateToChild, + onNavigateToParent: handleNavigateToParent, }; return ( @@ -333,6 +362,7 @@ export function App() { activeSession={session.activeSession} onNewSession={session.handleNewSession} onToggleSessionList={session.toggleSessionList} + onNavigateToParent={isChildSession ? handleNavigateToParent : undefined} /> {session.showSessionList && ( 0 && ( )} - + {!isChildSession && ( + + )} ) : ( diff --git a/webview/components/atoms/icons/icons.tsx b/webview/components/atoms/icons/icons.tsx index e5f8107..4419866 100644 --- a/webview/components/atoms/icons/icons.tsx +++ b/webview/components/atoms/icons/icons.tsx @@ -289,3 +289,23 @@ export function EyeOffIcon({ width = 14, height = 14, ...props }: IconProps) { ); } + +// ─── Child Session Navigation ───────────────────────────────────────── + +/** Codicon: arrow-left — back navigation */ +export function BackIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: person — agent/subagent icon */ +export function AgentIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} diff --git a/webview/components/organisms/ChatHeader/ChatHeader.tsx b/webview/components/organisms/ChatHeader/ChatHeader.tsx index bc0d30f..639a32c 100644 --- a/webview/components/organisms/ChatHeader/ChatHeader.tsx +++ b/webview/components/organisms/ChatHeader/ChatHeader.tsx @@ -1,27 +1,36 @@ import type { Session } from "@opencode-ai/sdk"; import { useLocale } from "../../../locales"; import { IconButton } from "../../atoms/IconButton"; -import { AddIcon, ListIcon } from "../../atoms/icons"; +import { AddIcon, BackIcon, ListIcon } from "../../atoms/icons"; import styles from "./ChatHeader.module.css"; type Props = { activeSession: Session | null; onNewSession: () => void; onToggleSessionList: () => void; + onNavigateToParent?: () => void; }; -export function ChatHeader({ activeSession, onNewSession, onToggleSessionList }: Props) { +export function ChatHeader({ activeSession, onNewSession, onToggleSessionList, onNavigateToParent }: Props) { const t = useLocale(); return (
- - - + {onNavigateToParent ? ( + + + + ) : ( + + + + )} {activeSession?.title || t["header.title.fallback"]}
- - - + {!onNavigateToParent && ( + + + + )}
); diff --git a/webview/components/organisms/MessageItem/MessageItem.tsx b/webview/components/organisms/MessageItem/MessageItem.tsx index a8c3e66..9b7c947 100644 --- a/webview/components/organisms/MessageItem/MessageItem.tsx +++ b/webview/components/organisms/MessageItem/MessageItem.tsx @@ -8,6 +8,7 @@ import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "../../a import { ShellResultView } from "../../molecules/ShellResultView"; import { TextPartView } from "../../molecules/TextPartView"; import { PermissionView } from "../PermissionView"; +import { type SubtaskPart, SubtaskPartView } from "../SubtaskPartView"; import { ToolPartView } from "../ToolPartView"; import styles from "./MessageItem.module.css"; @@ -20,7 +21,7 @@ type Props = { export function MessageItem({ message, activeSessionId, permissions, onEditAndResend }: Props) { const t = useLocale(); - const { isShellMessage } = useAppContext(); + const { isShellMessage, childSessions, onNavigateToChild } = useAppContext(); const { info, parts } = message; const isUser = info.role === "user"; const isShellUser = isUser && isShellMessage(info.id); @@ -151,6 +152,15 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe return ; case "tool": return ; + case "subtask": + return ( + + ); case "reasoning": return ; default: diff --git a/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css b/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css new file mode 100644 index 0000000..8e3023d --- /dev/null +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css @@ -0,0 +1,52 @@ +.root { + margin: 2px 0; + border-radius: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + cursor: pointer; + font-size: 12px; + border-radius: 6px; + user-select: none; +} + +.header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.icon { + display: flex; + align-items: center; + flex-shrink: 0; + color: var(--vscode-terminal-ansiBlue, #3794ff); +} + +.action { + font-size: 11px; + font-weight: 600; + flex-shrink: 0; + padding: 0 5px; + border-radius: 3px; + color: var(--vscode-terminal-ansiBlue, #3794ff); + background-color: color-mix(in srgb, var(--vscode-terminal-ansiBlue, #3794ff) 12%, transparent); +} + +.title { + flex: 1; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + +.navigate { + display: flex; + align-items: center; + flex-shrink: 0; + color: var(--vscode-textLink-foreground); +} diff --git a/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx new file mode 100644 index 0000000..52ce1ed --- /dev/null +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx @@ -0,0 +1,61 @@ +import type { Session } from "@opencode-ai/sdk"; +import { useLocale } from "../../../locales"; +import { AgentIcon, ChevronRightIcon } from "../../atoms/icons"; +import styles from "./SubtaskPartView.module.css"; + +/** SDK の subtask パート型(Part ユニオンの一メンバー) */ +type SubtaskPart = { + id: string; + sessionID: string; + messageID: string; + type: "subtask"; + prompt: string; + description: string; + agent: string; +}; + +type Props = { + part: SubtaskPart; + childSessions: Session[]; + onNavigateToChild: (sessionId: string) => void; +}; + +/** + * subtask パートを ToolPartView と同じ折りたたみバー形式で表示するコンポーネント。 + * クリックで対応する子セッションにナビゲートする。 + */ +export function SubtaskPartView({ part, childSessions, onNavigateToChild }: Props) { + const t = useLocale(); + + // subtask パートに対応する子セッションを探す。 + // 子セッションの title がサブタスクの description と一致するケースが多い。 + // 見つからない場合はナビゲーション不可(バー表示のみ)。 + const matchedChild = childSessions.find((s) => s.title === part.description || s.title === part.prompt); + + const handleClick = () => { + if (matchedChild) { + onNavigateToChild(matchedChild.id); + } + }; + + return ( +
+
+ + + + {t["childSession.agent"]} + + {part.agent}: {part.description || part.prompt} + + {matchedChild && ( + + + + )} +
+
+ ); +} + +export type { SubtaskPart }; diff --git a/webview/components/organisms/SubtaskPartView/index.ts b/webview/components/organisms/SubtaskPartView/index.ts new file mode 100644 index 0000000..f4984e6 --- /dev/null +++ b/webview/components/organisms/SubtaskPartView/index.ts @@ -0,0 +1 @@ +export { type SubtaskPart, SubtaskPartView } from "./SubtaskPartView"; diff --git a/webview/contexts/AppContext.tsx b/webview/contexts/AppContext.tsx index 74806b5..381486b 100644 --- a/webview/contexts/AppContext.tsx +++ b/webview/contexts/AppContext.tsx @@ -57,6 +57,11 @@ export type AppContextValue = { onOpenTerminal: () => void; localeSetting: LocaleSetting; onLocaleSettingChange: (setting: LocaleSetting) => void; + + // Child Sessions + childSessions: Session[]; + onNavigateToChild: (sessionId: string) => void; + onNavigateToParent: () => void; }; // Context は AppProvider 内でのみ使用される想定。 diff --git a/webview/locales/en.ts b/webview/locales/en.ts index 2daa807..98647a9 100644 --- a/webview/locales/en.ts +++ b/webview/locales/en.ts @@ -107,6 +107,10 @@ export const en = { "fileChanges.noChanges": "No file changes", "fileChanges.openDiff": "Open in diff editor", "fileChanges.toggle": "File changes", + + // ChildSession + "childSession.agent": "Agent", + "childSession.backToParent": "Back to parent session", } as const; export type LocaleKeys = keyof typeof en; diff --git a/webview/locales/ja.ts b/webview/locales/ja.ts index 0a453ba..aa6f731 100644 --- a/webview/locales/ja.ts +++ b/webview/locales/ja.ts @@ -109,4 +109,8 @@ export const ja: typeof en = { "fileChanges.noChanges": "ファイル変更なし", "fileChanges.openDiff": "差分エディタで開く", "fileChanges.toggle": "ファイル変更", + + // ChildSession + "childSession.agent": "エージェント", + "childSession.backToParent": "親セッションに戻る", }; From 38f9f595b302f17d7603ade776f84d12840e39a4 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:06:56 +0900 Subject: [PATCH 03/15] test: add child session navigation unit and scenario tests --- .../organisms/SubtaskPartView.test.tsx | 96 +++++++++++++ webview/__tests__/factories.ts | 20 +++ .../scenarios/17-child-session-nav.test.tsx | 130 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 webview/__tests__/components/organisms/SubtaskPartView.test.tsx create mode 100644 webview/__tests__/scenarios/17-child-session-nav.test.tsx diff --git a/webview/__tests__/components/organisms/SubtaskPartView.test.tsx b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx new file mode 100644 index 0000000..269001f --- /dev/null +++ b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx @@ -0,0 +1,96 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SubtaskPartView } from "../../../components/organisms/SubtaskPartView"; +import { createSession, createSubtaskPart } from "../../factories"; + +const defaultProps = { + part: createSubtaskPart("coder", "Implement feature X"), + childSessions: [] as ReturnType[], + onNavigateToChild: vi.fn(), +}; + +describe("SubtaskPartView", () => { + // when rendered with a subtask part + context("subtask パートを描画した場合", () => { + // renders the header + it("ヘッダーをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".header")).toBeInTheDocument(); + }); + + // renders the agent icon + it("エージェントアイコンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".icon")).toBeInTheDocument(); + }); + + // renders the action label "Agent" + it("Agent ラベルをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".action")?.textContent).toBe("Agent"); + }); + + // renders agent name and description in title + it("エージェント名と説明をタイトルに表示すること", () => { + const { container } = render(); + expect(container.querySelector(".title")?.textContent).toBe("coder: Implement feature X"); + }); + }); + + // when a matching child session exists + context("対応する子セッションがある場合", () => { + const childSession = createSession({ id: "child-1", title: "Implement feature X" }); + const propsWithChild = { + ...defaultProps, + childSessions: [childSession], + onNavigateToChild: vi.fn(), + }; + + // renders the chevron navigate icon + it("ナビゲーションシェブロンアイコンを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + }); + + // calls onNavigateToChild with child session id on click + it("クリックで子セッションIDを渡して onNavigateToChild を呼ぶこと", async () => { + const user = userEvent.setup(); + const { container } = render(); + await user.click(container.querySelector(".header")!); + expect(propsWithChild.onNavigateToChild).toHaveBeenCalledWith("child-1"); + }); + }); + + // when no matching child session exists + context("対応する子セッションがない場合", () => { + // does not render the chevron navigate icon + it("ナビゲーションシェブロンアイコンを表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".navigate")).not.toBeInTheDocument(); + }); + + // does not call onNavigateToChild on click + it("クリックしても onNavigateToChild を呼ばないこと", async () => { + const user = userEvent.setup(); + const onNav = vi.fn(); + const { container } = render(); + await user.click(container.querySelector(".header")!); + expect(onNav).not.toHaveBeenCalled(); + }); + }); + + // when child session matches by prompt instead of description + context("prompt で子セッションが一致する場合", () => { + const part = createSubtaskPart("coder", "desc-no-match", { prompt: "Find the bug" }); + const childSession = createSession({ id: "child-2", title: "Find the bug" }); + + // renders the chevron navigate icon + it("ナビゲーションシェブロンアイコンを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/factories.ts b/webview/__tests__/factories.ts index c1e9225..c9bb031 100644 --- a/webview/__tests__/factories.ts +++ b/webview/__tests__/factories.ts @@ -59,6 +59,26 @@ export function createToolPart(tool: string, overrides: Partial = {}): } as unknown as ToolPart; } +// --- SubtaskPart --- + +export function createSubtaskPart( + agent: string, + description: string, + overrides: Record = {}, +) { + return { + id: `part-${++partSeq}`, + type: "subtask" as const, + sessionID: "session-1", + messageID: "msg-1", + prompt: description, + description, + agent, + time: { created: Date.now(), updated: Date.now() }, + ...overrides, + }; +} + // --- Permission --- export function createPermission(overrides: Partial = {}): Permission { diff --git a/webview/__tests__/scenarios/17-child-session-nav.test.tsx b/webview/__tests__/scenarios/17-child-session-nav.test.tsx new file mode 100644 index 0000000..cf5ff9f --- /dev/null +++ b/webview/__tests__/scenarios/17-child-session-nav.test.tsx @@ -0,0 +1,130 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createMessage, createSession, createSubtaskPart, createTextPart } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** 親セッション + subtask パート + 子セッションをセットアップする */ +async function setupParentWithSubtask() { + renderApp(); + const parentSession = createSession({ id: "parent-1", title: "Parent Chat" }); + await sendExtMessage({ type: "activeSession", session: parentSession }); + + const assistantMsg = createMessage({ id: "m1", sessionID: "parent-1", role: "assistant" }); + const textPart = createTextPart("Let me delegate this.", { messageID: "m1" }); + const subtaskPart = createSubtaskPart("coder", "Fix the bug", { messageID: "m1" }); + + await sendExtMessage({ + type: "messages", + sessionId: "parent-1", + messages: [{ info: assistantMsg, parts: [textPart, subtaskPart as any] }], + }); + + const childSession = createSession({ id: "child-1", title: "Fix the bug", parentID: "parent-1" }); + await sendExtMessage({ type: "childSessions", sessionId: "parent-1", children: [childSession] }); + + vi.mocked(postMessage).mockClear(); + return { parentSession, childSession }; +} + +// Child session navigation +describe("子セッションナビゲーション", () => { + // Subtask part display in parent session + context("親セッションに subtask パートがある場合", () => { + beforeEach(async () => { + await setupParentWithSubtask(); + }); + + // displays the Agent label + it("Agent ラベルが表示されること", () => { + expect(screen.getByText("Agent")).toBeInTheDocument(); + }); + + // displays the agent name and description + it("エージェント名と説明が表示されること", () => { + expect(screen.getByText("coder: Fix the bug")).toBeInTheDocument(); + }); + }); + + // Clicking subtask to navigate to child session + context("subtask パートをクリックした場合", () => { + beforeEach(async () => { + await setupParentWithSubtask(); + }); + + // sends selectSession message for child session + it("子セッションの selectSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + const header = document.querySelector(".header:last-of-type") as HTMLElement; + await user.click(header); + expect(postMessage).toHaveBeenCalledWith({ + type: "selectSession", + sessionId: "child-1", + }); + }); + }); + + // Child session shows back button instead of session list toggle + context("子セッションを表示した場合", () => { + beforeEach(async () => { + renderApp(); + const childSession = createSession({ id: "child-1", title: "Fix the bug", parentID: "parent-1" }); + await sendExtMessage({ type: "activeSession", session: childSession }); + }); + + // shows back button + it("戻るボタンが表示されること", () => { + expect(screen.getByTitle("Back to parent session")).toBeInTheDocument(); + }); + + // does not show new chat button + it("新規チャットボタンが表示されないこと", () => { + expect(screen.queryByTitle("New chat")).not.toBeInTheDocument(); + }); + + // hides the input area + it("入力エリアが非表示になること", () => { + expect(screen.queryByPlaceholderText("Ask anything...")).not.toBeInTheDocument(); + }); + }); + + // Clicking back button navigates to parent + context("戻るボタンをクリックした場合", () => { + beforeEach(async () => { + renderApp(); + const childSession = createSession({ id: "child-1", title: "Fix the bug", parentID: "parent-1" }); + await sendExtMessage({ type: "activeSession", session: childSession }); + vi.mocked(postMessage).mockClear(); + }); + + // sends selectSession message for parent session + it("親セッションの selectSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Back to parent session")); + expect(postMessage).toHaveBeenCalledWith({ + type: "selectSession", + sessionId: "parent-1", + }); + }); + }); + + // Parent session does not show back button + context("親セッションの場合", () => { + beforeEach(async () => { + renderApp(); + const parentSession = createSession({ id: "parent-1", title: "Parent Chat" }); + await sendExtMessage({ type: "activeSession", session: parentSession }); + }); + + // does not show back button + it("戻るボタンが表示されないこと", () => { + expect(screen.queryByTitle("Back to parent session")).not.toBeInTheDocument(); + }); + + // shows new chat button + it("新規チャットボタンが表示されること", () => { + expect(screen.getByTitle("New chat")).toBeInTheDocument(); + }); + }); +}); From 553417b441761caa3e4ade605d6836d611644a66 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:30:39 +0900 Subject: [PATCH 04/15] feat: add @agent mention SDK and protocol layer --- src/chat-view-provider.ts | 13 +++++++++++-- src/opencode-client.ts | 26 +++++++++++++++++++++++++- webview/vscode-api.ts | 7 +++++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index fea1b76..b07d537 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as vscode from "vscode"; import type { + Agent, Event, FileDiff, Message, @@ -41,7 +42,8 @@ export type ExtToWebviewMessage = | { type: "modelUpdated"; model: string; default: Record } | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } | { type: "sessionTodos"; sessionId: string; todos: Todo[] } - | { type: "childSessions"; sessionId: string; children: Session[] }; + | { type: "childSessions"; sessionId: string; children: Session[] } + | { type: "agents"; agents: Agent[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -51,6 +53,7 @@ export type WebviewToExtMessage = text: string; model?: { providerID: string; modelID: string }; files?: FileAttachment[]; + agent?: string; } | { type: "createSession"; title?: string } | { type: "listSessions" } @@ -80,6 +83,7 @@ export type WebviewToExtMessage = | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } | { type: "getChildSessions"; sessionId: string } + | { type: "getAgents" } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; @@ -160,7 +164,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { break; } case "sendMessage": { - await this.connection.sendMessage(message.sessionId, message.text, message.model, message.files); + await this.connection.sendMessage(message.sessionId, message.text, message.model, message.files, message.agent); break; } case "createSession": { @@ -360,6 +364,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "childSessions", sessionId: message.sessionId, children }); break; } + case "getAgents": { + const agents = await this.connection.getAgents(); + this.postMessage({ type: "agents", agents }); + break; + } case "openDiffEditor": { // 仮想ドキュメントを使って VS Code のネイティブ diff エディタを開く const beforeUri = vscode.Uri.parse( diff --git a/src/opencode-client.ts b/src/opencode-client.ts index fa29a16..4121cee 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; import { + type Agent, type Config, createOpencodeClient, createOpencodeServer, @@ -17,7 +18,20 @@ import { } from "@opencode-ai/sdk"; import * as vscode from "vscode"; -export type { Event, Session, Message, Part, Provider, McpStatus, ToolListItem, Config, OpenCodePath, FileDiff, Todo }; +export type { + Agent, + Event, + Session, + Message, + Part, + Provider, + McpStatus, + ToolListItem, + Config, + OpenCodePath, + FileDiff, + Todo, +}; // provider.list() が返す生データ型 export type ProviderListResult = { @@ -180,6 +194,7 @@ export class OpenCodeConnection { text: string, model?: { providerID: string; modelID: string }, files?: Array<{ filePath: string; fileName: string }>, + agent?: string, ): Promise { const client = this.requireClient(); const parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename: string }> = @@ -203,6 +218,7 @@ export class OpenCodeConnection { body: { parts, model, + agent, }, }); } @@ -276,6 +292,14 @@ export class OpenCodeConnection { return response.data!; } + // --- Agent API --- + + async getAgents(): Promise { + const client = this.requireClient(); + const response = await client.app.agents(); + return response.data!; + } + // --- Session Diff API --- async getSessionDiff(sessionId: string): Promise { diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index 1017943..4792db1 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, Todo } from "@opencode-ai/sdk"; +import type { Agent, Event, FileDiff, Message, Part, Provider, Session, Todo } from "@opencode-ai/sdk"; // --- File attachment --- export type FileAttachment = { @@ -68,7 +68,8 @@ export type ExtToWebviewMessage = | { type: "modelUpdated"; model: string; default: Record } | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } | { type: "sessionTodos"; sessionId: string; todos: Todo[] } - | { type: "childSessions"; sessionId: string; children: Session[] }; + | { type: "childSessions"; sessionId: string; children: Session[] } + | { type: "agents"; agents: Agent[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -78,6 +79,7 @@ export type WebviewToExtMessage = text: string; model?: { providerID: string; modelID: string }; files?: FileAttachment[]; + agent?: string; } | { type: "createSession"; title?: string } | { type: "listSessions" } @@ -107,6 +109,7 @@ export type WebviewToExtMessage = | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } | { type: "getChildSessions"; sessionId: string } + | { type: "getAgents" } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; From 31e74a9e50a18066cca46a43d0cc8f09d63b5a9a Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:30:44 +0900 Subject: [PATCH 05/15] feat: add @agent mention UI components --- webview/App.tsx | 12 +- .../AgentPopup/AgentPopup.module.css | 21 ++++ .../molecules/AgentPopup/AgentPopup.tsx | 31 +++++ .../components/molecules/AgentPopup/index.ts | 1 + .../organisms/InputArea/InputArea.module.css | 34 ++++++ .../organisms/InputArea/InputArea.tsx | 109 ++++++++++++++++-- webview/locales/en.ts | 3 + webview/locales/ja.ts | 3 + 8 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 webview/components/molecules/AgentPopup/AgentPopup.module.css create mode 100644 webview/components/molecules/AgentPopup/AgentPopup.tsx create mode 100644 webview/components/molecules/AgentPopup/index.ts diff --git a/webview/App.tsx b/webview/App.tsx index 3256824..9edb5dd 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,4 +1,4 @@ -import type { Event, Session, Todo } from "@opencode-ai/sdk"; +import type { Agent, Event, Session, Todo } from "@opencode-ai/sdk"; import { useCallback, useEffect, useState } from "react"; import { EmptyState } from "./components/molecules/EmptyState"; import { FileChangesHeader } from "./components/molecules/FileChangesHeader"; @@ -34,6 +34,7 @@ export function App() { const [workspaceFiles, setWorkspaceFiles] = useState([]); const [todos, setTodos] = useState([]); const [childSessions, setChildSessions] = useState([]); + const [agents, setAgents] = useState([]); const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; @@ -163,11 +164,16 @@ export function App() { } break; } + case "agents": { + setAgents(data.agents); + break; + } } }; window.addEventListener("message", handler); postMessage({ type: "ready" }); postMessage({ type: "getOpenEditors" }); + postMessage({ type: "getAgents" }); return () => window.removeEventListener("message", handler); }, [ session.activeSession?.id, @@ -186,7 +192,7 @@ export function App() { // Cross-cutting action handlers (span multiple hooks) const handleSend = useCallback( - (text: string, files: FileAttachment[]) => { + (text: string, files: FileAttachment[], agent?: string) => { if (!session.activeSession) return; postMessage({ type: "sendMessage", @@ -194,6 +200,7 @@ export function App() { text, model: prov.selectedModel ?? undefined, files: files.length > 0 ? files : undefined, + agent, }); }, [session.activeSession, prov.selectedModel], @@ -411,6 +418,7 @@ export function App() { onOpenTerminal={handleOpenTerminal} localeSetting={locale.localeSetting} onLocaleSettingChange={locale.handleLocaleSettingChange} + agents={agents} /> )} diff --git a/webview/components/molecules/AgentPopup/AgentPopup.module.css b/webview/components/molecules/AgentPopup/AgentPopup.module.css new file mode 100644 index 0000000..f8ba5c0 --- /dev/null +++ b/webview/components/molecules/AgentPopup/AgentPopup.module.css @@ -0,0 +1,21 @@ +.root { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + min-width: 240px; + max-width: 360px; + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-quickInput-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.empty { + padding: 8px 10px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} diff --git a/webview/components/molecules/AgentPopup/AgentPopup.tsx b/webview/components/molecules/AgentPopup/AgentPopup.tsx new file mode 100644 index 0000000..87958b0 --- /dev/null +++ b/webview/components/molecules/AgentPopup/AgentPopup.tsx @@ -0,0 +1,31 @@ +import type { Agent } from "@opencode-ai/sdk"; +import { useLocale } from "../../../locales"; +import { ListItem } from "../../atoms/ListItem"; +import styles from "./AgentPopup.module.css"; + +type Props = { + agents: Agent[]; + onSelectAgent: (agent: Agent) => void; + agentPopupRef: React.RefObject; +}; + +export function AgentPopup({ agents, onSelectAgent, agentPopupRef }: Props) { + const t = useLocale(); + + return ( +
+ {agents.length > 0 ? ( + agents.map((agent) => ( + onSelectAgent(agent)} + /> + )) + ) : ( +
{t["input.noAgents"]}
+ )} +
+ ); +} diff --git a/webview/components/molecules/AgentPopup/index.ts b/webview/components/molecules/AgentPopup/index.ts new file mode 100644 index 0000000..7569ecc --- /dev/null +++ b/webview/components/molecules/AgentPopup/index.ts @@ -0,0 +1 @@ +export { AgentPopup } from "./AgentPopup"; diff --git a/webview/components/organisms/InputArea/InputArea.module.css b/webview/components/organisms/InputArea/InputArea.module.css index 3418bf3..be96cb8 100644 --- a/webview/components/organisms/InputArea/InputArea.module.css +++ b/webview/components/organisms/InputArea/InputArea.module.css @@ -91,3 +91,37 @@ border-radius: 3px; width: fit-content; } + +.agentIndicator { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + margin-bottom: 2px; + font-size: 11px; + color: var(--vscode-terminal-ansiBlue, #61afef); + background-color: var(--vscode-badge-background); + border-radius: 3px; + width: fit-content; +} + +.agentName { + font-weight: 600; +} + +.agentClear { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.agentClear:hover { + opacity: 1; +} diff --git a/webview/components/organisms/InputArea/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx index 0ce3b10..de3dd08 100644 --- a/webview/components/organisms/InputArea/InputArea.tsx +++ b/webview/components/organisms/InputArea/InputArea.tsx @@ -1,4 +1,4 @@ -import type { Provider } from "@opencode-ai/sdk"; +import type { Agent, Provider } from "@opencode-ai/sdk"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; import { useClickOutside } from "../../../hooks/useClickOutside"; import type { LocaleSetting } from "../../../locales"; @@ -7,8 +7,9 @@ import type { AllProvidersData, FileAttachment } from "../../../vscode-api"; import { postMessage } from "../../../vscode-api"; import { ContextIndicator } from "../../atoms/ContextIndicator"; import { IconButton } from "../../atoms/IconButton"; -import { ChevronRightIcon, GearIcon, SendIcon, StopIcon, TerminalIcon } from "../../atoms/icons"; +import { AgentIcon, ChevronRightIcon, CloseIcon, GearIcon, SendIcon, StopIcon, TerminalIcon } from "../../atoms/icons"; import { Popover } from "../../atoms/Popover"; +import { AgentPopup } from "../../molecules/AgentPopup"; import { FileAttachmentBar } from "../../molecules/FileAttachmentBar"; import { HashFilePopup } from "../../molecules/HashFilePopup"; import { ModelSelector } from "../../molecules/ModelSelector"; @@ -16,7 +17,7 @@ import { ToolConfigPanel } from "../../organisms/ToolConfigPanel"; import styles from "./InputArea.module.css"; type Props = { - onSend: (text: string, files: FileAttachment[]) => void; + onSend: (text: string, files: FileAttachment[], agent?: string) => void; onShellExecute: (command: string) => void; onAbort: () => void; isBusy: boolean; @@ -37,6 +38,7 @@ type Props = { onOpenTerminal: () => void; localeSetting: LocaleSetting; onLocaleSettingChange: (setting: LocaleSetting) => void; + agents: Agent[]; }; export function InputArea({ @@ -61,6 +63,7 @@ export function InputArea({ onOpenTerminal, localeSetting, onLocaleSettingChange, + agents, }: Props) { const t = useLocale(); const [text, setText] = useState(""); @@ -73,10 +76,18 @@ export function InputArea({ startIndex: -1, }); const [hashQuery, setHashQuery] = useState(""); + // @ トリガー用 + const [atTrigger, setAtTrigger] = useState<{ active: boolean; startIndex: number }>({ + active: false, + startIndex: -1, + }); + const [atQuery, setAtQuery] = useState(""); + const [selectedAgent, setSelectedAgent] = useState(null); const textareaRef = useRef(null); const composingRef = useRef(false); const filePickerRef = useRef(null); const hashPopupRef = useRef(null); + const agentPopupRef = useRef(null); // チェックポイントからの復元時にテキストをプリフィルする useEffect(() => { @@ -150,6 +161,16 @@ export function InputArea({ hashTrigger.active, ); + // 外部クリックで @ ポップアップを閉じる + useClickOutside( + [agentPopupRef, textareaRef], + () => { + setAtTrigger({ active: false, startIndex: -1 }); + setAtQuery(""); + }, + atTrigger.active, + ); + // # トリガー: ワークスペースファイルを検索する useEffect(() => { if (hashTrigger.active) { @@ -157,6 +178,30 @@ export function InputArea({ } }, [hashTrigger.active, hashQuery]); + // @ トリガー: エージェント選択時のハンドラ + const selectAgent = useCallback( + (agent: Agent) => { + setSelectedAgent(agent); + // テキストから @query を削除する + if (atTrigger.active) { + setText((prev) => { + const before = prev.slice(0, atTrigger.startIndex); + const after = prev.slice(atTrigger.startIndex + 1 + atQuery.length); + return before + after; + }); + } + setAtTrigger({ active: false, startIndex: -1 }); + setAtQuery(""); + textareaRef.current?.focus(); + }, + [atTrigger, atQuery], + ); + + // 選択済みエージェントを解除する + const clearAgent = useCallback(() => { + setSelectedAgent(null); + }, []); + // ! プレフィクスでシェルコマンドモードかどうかを判定する const isShellMode = text.startsWith("!"); @@ -170,14 +215,15 @@ export function InputArea({ onShellExecute(command); } } else { - onSend(trimmed, attachedFiles); + onSend(trimmed, attachedFiles, selectedAgent?.name); } setText(""); setAttachedFiles([]); + setSelectedAgent(null); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - }, [text, attachedFiles, onSend, onShellExecute]); + }, [text, attachedFiles, onSend, onShellExecute, selectedAgent?.name]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -187,6 +233,12 @@ export function InputArea({ setHashQuery(""); return; } + // Escape で @ ポップアップを閉じる + if (e.key === "Escape" && atTrigger.active) { + setAtTrigger({ active: false, startIndex: -1 }); + setAtQuery(""); + return; + } // IME 変換中は送信しない if (e.key === "Enter" && !e.shiftKey && !composingRef.current) { e.preventDefault(); @@ -196,10 +248,14 @@ export function InputArea({ setHashTrigger({ active: false, startIndex: -1 }); setHashQuery(""); } + if (atTrigger.active) { + setAtTrigger({ active: false, startIndex: -1 }); + setAtQuery(""); + } handleSend(); } }, - [handleSend, isBusy, hashTrigger.active], + [handleSend, isBusy, hashTrigger.active, atTrigger.active], ); const handleTextChange = useCallback( @@ -222,6 +278,15 @@ export function InputArea({ postMessage({ type: "getOpenEditors" }); return; } + if ( + addedChar === "@" && + (cursorPos === 1 || newText[cursorPos - 2] === " " || newText[cursorPos - 2] === "\n") + ) { + // @ の前が空白・改行・先頭の場合にトリガーを開始 + setAtTrigger({ active: true, startIndex: cursorPos - 1 }); + setAtQuery(""); + return; + } } // # トリガーがアクティブなら、クエリを更新する @@ -235,8 +300,19 @@ export function InputArea({ setHashQuery(queryPart); } } + + // @ トリガーがアクティブなら、クエリを更新する + if (atTrigger.active) { + const queryPart = newText.slice(atTrigger.startIndex + 1, cursorPos); + if (/[\s]/.test(queryPart) || cursorPos <= atTrigger.startIndex) { + setAtTrigger({ active: false, startIndex: -1 }); + setAtQuery(""); + } else { + setAtQuery(queryPart); + } + } }, - [text, hashTrigger], + [text, hashTrigger, atTrigger], ); const handleInput = useCallback(() => { @@ -273,6 +349,11 @@ export function InputArea({ ? attachedFiles.some((f) => f.filePath === activeEditorFile.filePath) : false; + // @ トリガーのエージェント候補 + const filteredAgents = atQuery + ? agents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) + : agents.slice(0, 10); + return (
@@ -330,6 +411,20 @@ export function InputArea({ {hashTrigger.active && ( )} + {/* @ トリガー エージェント候補ポップアップ */} + {atTrigger.active && ( + + )} + {/* 選択済みエージェントインジケーター */} + {selectedAgent && ( +
+ + @{selectedAgent.name} + +
+ )}
diff --git a/webview/locales/en.ts b/webview/locales/en.ts index 98647a9..5459330 100644 --- a/webview/locales/en.ts +++ b/webview/locales/en.ts @@ -111,6 +111,9 @@ export const en = { // ChildSession "childSession.agent": "Agent", "childSession.backToParent": "Back to parent session", + + // AgentMention + "input.noAgents": "No agents available", } as const; export type LocaleKeys = keyof typeof en; diff --git a/webview/locales/ja.ts b/webview/locales/ja.ts index aa6f731..b25114e 100644 --- a/webview/locales/ja.ts +++ b/webview/locales/ja.ts @@ -113,4 +113,7 @@ export const ja: typeof en = { // ChildSession "childSession.agent": "エージェント", "childSession.backToParent": "親セッションに戻る", + + // AgentMention + "input.noAgents": "利用可能なエージェントがありません", }; From f14171053d8dd146198bf6b96935330b97b3ee28 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:30:49 +0900 Subject: [PATCH 06/15] test: add agent mention unit and scenario tests --- .../components/molecules/AgentPopup.test.tsx | 65 +++++++ webview/__tests__/factories.ts | 6 +- .../scenarios/18-agent-mention.test.tsx | 177 ++++++++++++++++++ 3 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 webview/__tests__/components/molecules/AgentPopup.test.tsx create mode 100644 webview/__tests__/scenarios/18-agent-mention.test.tsx diff --git a/webview/__tests__/components/molecules/AgentPopup.test.tsx b/webview/__tests__/components/molecules/AgentPopup.test.tsx new file mode 100644 index 0000000..1c81fa8 --- /dev/null +++ b/webview/__tests__/components/molecules/AgentPopup.test.tsx @@ -0,0 +1,65 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AgentPopup } from "../../../components/molecules/AgentPopup"; + +function createAgent(name: string, description?: string) { + return { + name, + description, + mode: "subagent" as const, + builtIn: true, + permission: { edit: "ask" as const, bash: {} }, + tools: {}, + options: {}, + }; +} + +describe("AgentPopup", () => { + // when rendered with agents + context("エージェント一覧がある場合", () => { + const agents = [createAgent("coder", "Coding agent"), createAgent("researcher", "Research agent")]; + + // renders agent names + it("エージェント名を表示すること", () => { + const { container } = render( + , + ); + const titles = container.querySelectorAll(".title"); + expect(titles[0]?.textContent).toBe("coder"); + expect(titles[1]?.textContent).toBe("researcher"); + }); + + // renders agent descriptions + it("エージェントの説明を表示すること", () => { + const { container } = render( + , + ); + const descriptions = container.querySelectorAll(".description"); + expect(descriptions[0]?.textContent).toBe("Coding agent"); + }); + + // calls onSelectAgent when clicked + it("クリックで onSelectAgent を呼ぶこと", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + , + ); + const items = container.querySelectorAll(".root > div"); + await user.click(items[0]!); + expect(onSelect).toHaveBeenCalledWith(agents[0]); + }); + }); + + // when no agents available + context("エージェントがない場合", () => { + // shows empty message + it("空メッセージを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".empty")?.textContent).toBe("No agents available"); + }); + }); +}); diff --git a/webview/__tests__/factories.ts b/webview/__tests__/factories.ts index c9bb031..0bd70df 100644 --- a/webview/__tests__/factories.ts +++ b/webview/__tests__/factories.ts @@ -61,11 +61,7 @@ export function createToolPart(tool: string, overrides: Partial = {}): // --- SubtaskPart --- -export function createSubtaskPart( - agent: string, - description: string, - overrides: Record = {}, -) { +export function createSubtaskPart(agent: string, description: string, overrides: Record = {}) { return { id: `part-${++partSeq}`, type: "subtask" as const, diff --git a/webview/__tests__/scenarios/18-agent-mention.test.tsx b/webview/__tests__/scenarios/18-agent-mention.test.tsx new file mode 100644 index 0000000..294271b --- /dev/null +++ b/webview/__tests__/scenarios/18-agent-mention.test.tsx @@ -0,0 +1,177 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** エージェントメンションテスト用のセットアップ */ +async function setupWithAgents() { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + + // エージェント一覧を設定 + await sendExtMessage({ + type: "agents", + agents: [ + { + name: "coder", + description: "Coding agent", + mode: "subagent", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "researcher", + description: "Research agent", + mode: "subagent", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + ] as any, + }); + + vi.mocked(postMessage).mockClear(); +} + +// Agent mention +describe("エージェントメンション", () => { + // @ trigger shows agent popup + context("@ トリガーを入力した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // shows agent popup + it("エージェント候補ポップアップが表示されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + expect(screen.getByTestId("agent-popup")).toBeInTheDocument(); + }); + + // shows agent names in popup + it("エージェント名がポップアップに表示されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + expect(screen.getByText("coder")).toBeInTheDocument(); + expect(screen.getByText("researcher")).toBeInTheDocument(); + }); + + // filters agents by query + it("クエリでエージェントをフィルタすること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@cod"); + expect(screen.getByText("coder")).toBeInTheDocument(); + expect(screen.queryByText("researcher")).not.toBeInTheDocument(); + }); + }); + + // Selecting an agent + context("エージェントを選択した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // shows agent indicator + it("エージェントインジケーターが表示されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.click(screen.getByText("coder")); + expect(screen.getByText("@coder")).toBeInTheDocument(); + }); + + // closes the popup + it("ポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.click(screen.getByText("coder")); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + }); + + // removes @ from text + it("テキストから @ が削除されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)") as HTMLTextAreaElement; + await user.type(textarea, "@"); + await user.click(screen.getByText("coder")); + expect(textarea.value).toBe(""); + }); + }); + + // Sending with selected agent + context("エージェント選択後にメッセージを送信した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // includes agent in sendMessage + it("sendMessage に agent が含まれること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.click(screen.getByText("coder")); + await user.type(textarea, "Fix the bug"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Fix the bug", + agent: "coder", + }), + ); + }); + }); + + // Sending without agent + context("エージェント未選択でメッセージを送信した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // does not include agent in sendMessage + it("sendMessage に agent が含まれないこと", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Hello"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Hello", + }), + ); + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + agent: expect.anything(), + }), + ); + }); + }); + + // Escape closes popup + context("@ ポップアップ表示中に Escape を押した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // closes the agent popup + it("エージェントポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + expect(screen.getByTestId("agent-popup")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + }); + }); +}); From 86a90a7c02baa926b163346661b88da93e470086 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:43:45 +0900 Subject: [PATCH 07/15] fix: filter agents to subagent-only and refresh child sessions on session.created --- webview/App.tsx | 6 ++++++ webview/components/organisms/InputArea/InputArea.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/webview/App.tsx b/webview/App.tsx index 9edb5dd..4d1de8e 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -67,6 +67,12 @@ export function App() { if (event.type === "todo.updated" && event.properties.sessionID === session.activeSession?.id) { setTodos(event.properties.todos as Todo[]); } + + // session.created イベント時にアクティブセッションの子セッションを再取得する + // (サブエージェントが起動すると子セッションが作成されるため) + if (event.type === "session.created" && session.activeSession) { + postMessage({ type: "getChildSessions", sessionId: session.activeSession.id }); + } }, [ session.handleSessionEvent, diff --git a/webview/components/organisms/InputArea/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx index de3dd08..5d49757 100644 --- a/webview/components/organisms/InputArea/InputArea.tsx +++ b/webview/components/organisms/InputArea/InputArea.tsx @@ -349,10 +349,11 @@ export function InputArea({ ? attachedFiles.some((f) => f.filePath === activeEditorFile.filePath) : false; - // @ トリガーのエージェント候補 + // @ トリガーのエージェント候補(サブエージェントのみ表示) + const subagents = agents.filter((a) => a.mode === "subagent" || a.mode === "all"); const filteredAgents = atQuery - ? agents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) - : agents.slice(0, 10); + ? subagents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) + : subagents.slice(0, 10); return (
From 60b7229acc693c9b8ca2310d10d7057294d0ddde Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:43:49 +0900 Subject: [PATCH 08/15] test: update agent mention test to verify subagent-only filtering --- .../scenarios/18-agent-mention.test.tsx | 96 ++++++++++++------- 1 file changed, 59 insertions(+), 37 deletions(-) diff --git a/webview/__tests__/scenarios/18-agent-mention.test.tsx b/webview/__tests__/scenarios/18-agent-mention.test.tsx index 294271b..6efe19a 100644 --- a/webview/__tests__/scenarios/18-agent-mention.test.tsx +++ b/webview/__tests__/scenarios/18-agent-mention.test.tsx @@ -5,35 +5,53 @@ import { postMessage } from "../../vscode-api"; import { createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; +/** テスト用エージェントデータ(subagent + primary を含む) */ +const testAgents = [ + { + name: "general", + description: "General purpose subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "explore", + description: "Read-only exploration subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "deny", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "build", + description: "Primary build agent", + mode: "primary", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "plan", + description: "Primary plan agent", + mode: "primary", + builtIn: true, + permission: { edit: "deny", bash: {} }, + tools: {}, + options: {}, + }, +] as any; + /** エージェントメンションテスト用のセットアップ */ async function setupWithAgents() { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); - // エージェント一覧を設定 - await sendExtMessage({ - type: "agents", - agents: [ - { - name: "coder", - description: "Coding agent", - mode: "subagent", - builtIn: true, - permission: { edit: "ask", bash: {} }, - tools: {}, - options: {}, - }, - { - name: "researcher", - description: "Research agent", - mode: "subagent", - builtIn: true, - permission: { edit: "ask", bash: {} }, - tools: {}, - options: {}, - }, - ] as any, - }); + // エージェント一覧を設定(subagent + primary を含む) + await sendExtMessage({ type: "agents", agents: testAgents }); vi.mocked(postMessage).mockClear(); } @@ -54,22 +72,26 @@ describe("エージェントメンション", () => { expect(screen.getByTestId("agent-popup")).toBeInTheDocument(); }); - // shows agent names in popup - it("エージェント名がポップアップに表示されること", async () => { + // shows only subagent names in popup (not primary agents) + it("サブエージェントのみポップアップに表示されること", async () => { const user = userEvent.setup(); const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); await user.type(textarea, "@"); - expect(screen.getByText("coder")).toBeInTheDocument(); - expect(screen.getByText("researcher")).toBeInTheDocument(); + // subagent の general と explore が表示される + expect(screen.getByText("general")).toBeInTheDocument(); + expect(screen.getByText("explore")).toBeInTheDocument(); + // primary の build と plan は表示されない + expect(screen.queryByText("build")).not.toBeInTheDocument(); + expect(screen.queryByText("plan")).not.toBeInTheDocument(); }); // filters agents by query it("クエリでエージェントをフィルタすること", async () => { const user = userEvent.setup(); const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); - await user.type(textarea, "@cod"); - expect(screen.getByText("coder")).toBeInTheDocument(); - expect(screen.queryByText("researcher")).not.toBeInTheDocument(); + await user.type(textarea, "@gen"); + expect(screen.getByText("general")).toBeInTheDocument(); + expect(screen.queryByText("explore")).not.toBeInTheDocument(); }); }); @@ -84,8 +106,8 @@ describe("エージェントメンション", () => { const user = userEvent.setup(); const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); await user.type(textarea, "@"); - await user.click(screen.getByText("coder")); - expect(screen.getByText("@coder")).toBeInTheDocument(); + await user.click(screen.getByText("general")); + expect(screen.getByText("@general")).toBeInTheDocument(); }); // closes the popup @@ -93,7 +115,7 @@ describe("エージェントメンション", () => { const user = userEvent.setup(); const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); await user.type(textarea, "@"); - await user.click(screen.getByText("coder")); + await user.click(screen.getByText("general")); expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); }); @@ -102,7 +124,7 @@ describe("エージェントメンション", () => { const user = userEvent.setup(); const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)") as HTMLTextAreaElement; await user.type(textarea, "@"); - await user.click(screen.getByText("coder")); + await user.click(screen.getByText("general")); expect(textarea.value).toBe(""); }); }); @@ -118,14 +140,14 @@ describe("エージェントメンション", () => { const user = userEvent.setup(); const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); await user.type(textarea, "@"); - await user.click(screen.getByText("coder")); + await user.click(screen.getByText("general")); await user.type(textarea, "Fix the bug"); await user.keyboard("{Enter}"); expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "sendMessage", text: "Fix the bug", - agent: "coder", + agent: "general", }), ); }); From 3d79a46fff8ef28a07316135591978c4d9ebe9ed Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 05:52:19 +0900 Subject: [PATCH 09/15] fix: use AgentPartInput in parts instead of body.agent for subagent invocation --- src/opencode-client.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/opencode-client.ts b/src/opencode-client.ts index 4121cee..a165b15 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -197,8 +197,11 @@ export class OpenCodeConnection { agent?: string, ): Promise { const client = this.requireClient(); - const parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename: string }> = - [{ type: "text", text }]; + const parts: Array< + | { type: "text"; text: string } + | { type: "file"; mime: string; url: string; filename: string } + | { type: "agent"; name: string } + > = [{ type: "text", text }]; if (files) { for (const file of files) { // filePath はワークスペース相対パス。cwd 基準で絶対パスに変換する。 @@ -213,12 +216,17 @@ export class OpenCodeConnection { }); } } + // @agent メンションはサブエージェント呼び出しを示す AgentPartInput として parts に含める。 + // body.agent はプロンプトを処理するエージェントの切り替え(primary agent 間)に使われるため、 + // サブエージェント起動には AgentPartInput を使う。 + if (agent) { + parts.push({ type: "agent", name: agent }); + } await client.session.promptAsync({ path: { id: sessionId }, body: { parts, model, - agent, }, }); } From 39779f79154b8e9f1cdf0bb24aeaf5d76ba97b18 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 06:03:06 +0900 Subject: [PATCH 10/15] feat: render task tool calls as subtask display instead of code block dump --- .../organisms/MessageItem/MessageItem.tsx | 13 +++- .../SubtaskPartView.module.css | 30 ++++++++ .../SubtaskPartView/SubtaskPartView.tsx | 69 ++++++++++++++++--- .../organisms/SubtaskPartView/index.ts | 2 +- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/webview/components/organisms/MessageItem/MessageItem.tsx b/webview/components/organisms/MessageItem/MessageItem.tsx index 9b7c947..5537f29 100644 --- a/webview/components/organisms/MessageItem/MessageItem.tsx +++ b/webview/components/organisms/MessageItem/MessageItem.tsx @@ -8,7 +8,7 @@ import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "../../a import { ShellResultView } from "../../molecules/ShellResultView"; import { TextPartView } from "../../molecules/TextPartView"; import { PermissionView } from "../PermissionView"; -import { type SubtaskPart, SubtaskPartView } from "../SubtaskPartView"; +import { type SubtaskPart, SubtaskPartView, isTaskToolPart } from "../SubtaskPartView"; import { ToolPartView } from "../ToolPartView"; import styles from "./MessageItem.module.css"; @@ -151,6 +151,17 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe case "text": return ; case "tool": + // task ツール呼び出しはサブエージェント起動なので SubtaskPartView で表示する + if (isTaskToolPart(part)) { + return ( + + ); + } return ; case "subtask": return ( diff --git a/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css b/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css index 8e3023d..f93e55c 100644 --- a/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css @@ -50,3 +50,33 @@ flex-shrink: 0; color: var(--vscode-textLink-foreground); } + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.actionError { + color: var(--vscode-errorForeground, #f44747); + background-color: color-mix(in srgb, var(--vscode-errorForeground, #f44747) 12%, transparent); +} + +.errorBody { + padding: 4px 8px 4px 28px; +} + +.errorText { + margin: 0; + font-size: 11px; + color: var(--vscode-errorForeground, #f44747); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx index 52ce1ed..2bcd522 100644 --- a/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx @@ -1,6 +1,6 @@ -import type { Session } from "@opencode-ai/sdk"; +import type { Session, ToolPart } from "@opencode-ai/sdk"; import { useLocale } from "../../../locales"; -import { AgentIcon, ChevronRightIcon } from "../../atoms/icons"; +import { AgentIcon, ChevronRightIcon, SpinnerIcon } from "../../atoms/icons"; import styles from "./SubtaskPartView.module.css"; /** SDK の subtask パート型(Part ユニオンの一メンバー) */ @@ -14,23 +14,56 @@ type SubtaskPart = { agent: string; }; +/** SubtaskPart または task ToolPart から表示用データを抽出する */ +function extractSubtaskInfo(part: SubtaskPart | ToolPart): { + agent: string; + description: string; + prompt: string; + isActive: boolean; + isError: boolean; + errorMessage?: string; +} { + if (part.type === "subtask") { + return { + agent: part.agent, + description: part.description, + prompt: part.prompt, + isActive: false, + isError: false, + }; + } + // type === "tool" && tool === "task" + const input = (part.state.status !== "pending" ? part.state.input : {}) as Record; + const agent = (input.subagent_type as string) ?? (input.agent as string) ?? ""; + const description = (input.description as string) ?? ""; + const prompt = (input.prompt as string) ?? (input.task_instructions as string) ?? ""; + const isActive = part.state.status === "running" || part.state.status === "pending"; + const isError = part.state.status === "error"; + const errorMessage = isError ? (part.state as { error: string }).error : undefined; + return { agent, description, prompt, isActive, isError, errorMessage }; +} + type Props = { - part: SubtaskPart; + part: SubtaskPart | ToolPart; childSessions: Session[]; onNavigateToChild: (sessionId: string) => void; }; /** - * subtask パートを ToolPartView と同じ折りたたみバー形式で表示するコンポーネント。 - * クリックで対応する子セッションにナビゲートする。 + * subtask パートまたは task ツール呼び出しを表示するコンポーネント。 + * OpenCode は subtask を「task」ツール呼び出し(type: "tool", tool: "task")として + * 送信するため、両方に対応する。クリックで対応する子セッションにナビゲートする。 */ export function SubtaskPartView({ part, childSessions, onNavigateToChild }: Props) { const t = useLocale(); + const { agent, description, prompt, isActive, isError, errorMessage } = extractSubtaskInfo(part); + const displayText = description || prompt; + // subtask パートに対応する子セッションを探す。 // 子セッションの title がサブタスクの description と一致するケースが多い。 // 見つからない場合はナビゲーション不可(バー表示のみ)。 - const matchedChild = childSessions.find((s) => s.title === part.description || s.title === part.prompt); + const matchedChild = childSessions.find((s) => s.title === description || s.title === prompt); const handleClick = () => { if (matchedChild) { @@ -42,11 +75,17 @@ export function SubtaskPartView({ part, childSessions, onNavigateToChild }: Prop
- + {isActive ? ( + + ) : ( + + )} + + + {t["childSession.agent"]} - {t["childSession.agent"]} - - {part.agent}: {part.description || part.prompt} + + {agent ? `${agent}: ` : ""}{displayText} {matchedChild && ( @@ -54,8 +93,18 @@ export function SubtaskPartView({ part, childSessions, onNavigateToChild }: Prop )}
+ {isError && errorMessage && ( +
+
{errorMessage}
+
+ )}
); } +/** task ツール呼び出しかどうかを判定するヘルパー */ +export function isTaskToolPart(part: { type: string; tool?: string }): boolean { + return part.type === "tool" && part.tool === "task"; +} + export type { SubtaskPart }; diff --git a/webview/components/organisms/SubtaskPartView/index.ts b/webview/components/organisms/SubtaskPartView/index.ts index f4984e6..a51c9fe 100644 --- a/webview/components/organisms/SubtaskPartView/index.ts +++ b/webview/components/organisms/SubtaskPartView/index.ts @@ -1 +1 @@ -export { type SubtaskPart, SubtaskPartView } from "./SubtaskPartView"; +export { isTaskToolPart, type SubtaskPart, SubtaskPartView } from "./SubtaskPartView"; From 0b4ea197fd1073af1c658f612661cc8f9bbe7e21 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 06:03:12 +0900 Subject: [PATCH 11/15] test: add task tool part rendering and isTaskToolPart tests --- .../organisms/SubtaskPartView.test.tsx | 103 +++++++++++++++++- webview/__tests__/factories.ts | 25 +++++ .../scenarios/17-child-session-nav.test.tsx | 55 +++++++++- 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/webview/__tests__/components/organisms/SubtaskPartView.test.tsx b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx index 269001f..e7ce257 100644 --- a/webview/__tests__/components/organisms/SubtaskPartView.test.tsx +++ b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx @@ -1,8 +1,8 @@ import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { SubtaskPartView } from "../../../components/organisms/SubtaskPartView"; -import { createSession, createSubtaskPart } from "../../factories"; +import { SubtaskPartView, isTaskToolPart } from "../../../components/organisms/SubtaskPartView"; +import { createSession, createSubtaskPart, createTaskToolPart } from "../../factories"; const defaultProps = { part: createSubtaskPart("coder", "Implement feature X"), @@ -93,4 +93,103 @@ describe("SubtaskPartView", () => { expect(container.querySelector(".navigate")).toBeInTheDocument(); }); }); + + // task tool part rendering + context("task ツール呼び出し(type: tool, tool: task)を描画した場合", () => { + const taskPart = createTaskToolPart("general", "Search for relevant files"); + const taskProps = { + part: taskPart, + childSessions: [] as ReturnType[], + onNavigateToChild: vi.fn(), + }; + + // renders the Agent label + it("Agent ラベルをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".action")?.textContent).toBe("Agent"); + }); + + // renders the agent name and description from input + it("エージェント名と説明を state.input から表示すること", () => { + const { container } = render(); + expect(container.querySelector(".title")?.textContent).toBe("general: Search for relevant files"); + }); + + // navigates to matching child session + it("対応する子セッションにナビゲートできること", async () => { + const user = userEvent.setup(); + const childSession = createSession({ id: "child-task", title: "Search for relevant files" }); + const onNav = vi.fn(); + const { container } = render( + , + ); + await user.click(container.querySelector(".header")!); + expect(onNav).toHaveBeenCalledWith("child-task"); + }); + }); + + // task tool part with running status + context("task ツールが実行中の場合", () => { + const runningTaskPart = createTaskToolPart("explore", "Analyze codebase", { + state: { + status: "running", + title: "Analyze codebase", + input: { subagent_type: "explore", description: "Analyze codebase" }, + time: { start: Date.now() }, + }, + }); + + // shows spinner instead of agent icon + it("エージェントアイコンの代わりにスピナーを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".spinner")).toBeInTheDocument(); + }); + }); + + // task tool part with error status + context("task ツールがエラーの場合", () => { + const errorTaskPart = createTaskToolPart("general", "Failed task", { + state: { + status: "error", + input: { subagent_type: "general", description: "Failed task" }, + error: "Something went wrong", + time: { start: Date.now(), end: Date.now() }, + }, + }); + + // shows error styling on action label + it("アクションラベルにエラースタイルを適用すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".actionError")).toBeInTheDocument(); + }); + + // shows error message + it("エラーメッセージを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".errorText")?.textContent).toBe("Something went wrong"); + }); + }); +}); + +describe("isTaskToolPart", () => { + // identifies task tool parts + it("task ツールパートを識別すること", () => { + expect(isTaskToolPart({ type: "tool", tool: "task" })).toBe(true); + }); + + // rejects non-task tool parts + it("task 以外のツールパートを拒否すること", () => { + expect(isTaskToolPart({ type: "tool", tool: "read" })).toBe(false); + }); + + // rejects non-tool parts + it("type が tool でないパートを拒否すること", () => { + expect(isTaskToolPart({ type: "subtask" })).toBe(false); + }); }); diff --git a/webview/__tests__/factories.ts b/webview/__tests__/factories.ts index 0bd70df..f8ab4fb 100644 --- a/webview/__tests__/factories.ts +++ b/webview/__tests__/factories.ts @@ -59,6 +59,31 @@ export function createToolPart(tool: string, overrides: Partial = {}): } as unknown as ToolPart; } +/** task ツール呼び出し(サブエージェント起動)のファクトリ */ +export function createTaskToolPart( + agentName: string, + description: string, + overrides: Record = {}, +): ToolPart { + return { + id: `part-${++partSeq}`, + type: "tool", + tool: "task", + callID: `call-${partSeq}`, + sessionID: "session-1", + messageID: "msg-1", + state: { + status: "completed", + title: description, + input: { subagent_type: agentName, description, prompt: description }, + output: "Task completed successfully.", + metadata: {}, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + ...overrides, + } as unknown as ToolPart; +} + // --- SubtaskPart --- export function createSubtaskPart(agent: string, description: string, overrides: Record = {}) { diff --git a/webview/__tests__/scenarios/17-child-session-nav.test.tsx b/webview/__tests__/scenarios/17-child-session-nav.test.tsx index cf5ff9f..4c5f60f 100644 --- a/webview/__tests__/scenarios/17-child-session-nav.test.tsx +++ b/webview/__tests__/scenarios/17-child-session-nav.test.tsx @@ -2,7 +2,13 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage } from "../../vscode-api"; -import { createMessage, createSession, createSubtaskPart, createTextPart } from "../factories"; +import { + createMessage, + createSession, + createSubtaskPart, + createTaskToolPart, + createTextPart, +} from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; /** 親セッション + subtask パート + 子セッションをセットアップする */ @@ -127,4 +133,51 @@ describe("子セッションナビゲーション", () => { expect(screen.getByTitle("New chat")).toBeInTheDocument(); }); }); + + // task tool part rendered as subtask + context("task ツール呼び出しがメッセージに含まれる場合", () => { + beforeEach(async () => { + renderApp(); + const parentSession = createSession({ id: "parent-2", title: "Parent Chat 2" }); + await sendExtMessage({ type: "activeSession", session: parentSession }); + + const assistantMsg = createMessage({ id: "m2", sessionID: "parent-2", role: "assistant" }); + const textPart = createTextPart("Delegating to subagent.", { messageID: "m2" }); + const taskToolPart = createTaskToolPart("general", "Search for utils", { messageID: "m2" }); + + await sendExtMessage({ + type: "messages", + sessionId: "parent-2", + messages: [{ info: assistantMsg, parts: [textPart, taskToolPart as any] }], + }); + + const childSession = createSession({ id: "child-task-1", title: "Search for utils", parentID: "parent-2" }); + await sendExtMessage({ type: "childSessions", sessionId: "parent-2", children: [childSession] }); + vi.mocked(postMessage).mockClear(); + }); + + // displays Agent label (not Run label) + it("Agent ラベルが表示されること(Run ではなく)", () => { + const agentLabels = screen.getAllByText("Agent"); + expect(agentLabels.length).toBeGreaterThan(0); + }); + + // displays agent name and description from state.input + it("エージェント名と説明が state.input から表示されること", () => { + expect(screen.getByText("general: Search for utils")).toBeInTheDocument(); + }); + + // navigates to child session on click + it("クリックで子セッションにナビゲートすること", async () => { + const user = userEvent.setup(); + // Find the subtask header showing "general: Search for utils" + const title = screen.getByText("general: Search for utils"); + const header = title.closest(".header") as HTMLElement; + await user.click(header); + expect(postMessage).toHaveBeenCalledWith({ + type: "selectSession", + sessionId: "child-task-1", + }); + }); + }); }); From 08de313dc7c42af9da2ede27e78a1542645818d2 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 06:19:11 +0900 Subject: [PATCH 12/15] fix: use metadata sessionId and partial title match for child session navigation The server sets child session titles as 'description (@agent subagent)', which caused exact title matching to always fail. Now uses a 3-strategy approach: (1) metadata.sessionId direct match, (2) title includes description, (3) title includes prompt. --- .../SubtaskPartView/SubtaskPartView.tsx | 62 ++++++++++++++----- .../organisms/SubtaskPartView/index.ts | 2 +- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx index 2bcd522..ce7a10b 100644 --- a/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx @@ -43,6 +43,48 @@ function extractSubtaskInfo(part: SubtaskPart | ToolPart): { return { agent, description, prompt, isActive, isError, errorMessage }; } +/** + * task ToolPart に対応する子セッションを探す。 + * + * 優先順位: + * 1. ToolPart の state.metadata.sessionId で直接マッチ(最も信頼性が高い) + * 2. 子セッションの title が description を含むかどうか(サーバー側は + * `description + " (@agent subagent)"` 形式で title を設定する) + * 3. 子セッションの title が prompt を含むかどうか + */ +function findMatchingChild( + part: SubtaskPart | ToolPart, + childSessions: Session[], + description: string, + prompt: string, +): Session | undefined { + if (childSessions.length === 0) return undefined; + + // Strategy 1: metadata.sessionId で直接マッチ(task ToolPart のみ) + if (part.type === "tool" && part.state.status !== "pending") { + const metadata = (part.state as { metadata?: Record }).metadata; + const sessionId = metadata?.sessionId; + if (typeof sessionId === "string") { + const found = childSessions.find((s) => s.id === sessionId); + if (found) return found; + } + } + + // Strategy 2: title が description を含む(部分一致で比較) + if (description) { + const found = childSessions.find((s) => s.title.includes(description)); + if (found) return found; + } + + // Strategy 3: title が prompt を含む(部分一致で比較) + if (prompt) { + const found = childSessions.find((s) => s.title.includes(prompt)); + if (found) return found; + } + + return undefined; +} + type Props = { part: SubtaskPart | ToolPart; childSessions: Session[]; @@ -61,9 +103,7 @@ export function SubtaskPartView({ part, childSessions, onNavigateToChild }: Prop const displayText = description || prompt; // subtask パートに対応する子セッションを探す。 - // 子セッションの title がサブタスクの description と一致するケースが多い。 - // 見つからない場合はナビゲーション不可(バー表示のみ)。 - const matchedChild = childSessions.find((s) => s.title === description || s.title === prompt); + const matchedChild = findMatchingChild(part, childSessions, description, prompt); const handleClick = () => { if (matchedChild) { @@ -74,18 +114,11 @@ export function SubtaskPartView({ part, childSessions, onNavigateToChild }: Prop return (
- - {isActive ? ( - - ) : ( - - )} - - - {t["childSession.agent"]} - + {isActive ? : } + {t["childSession.agent"]} - {agent ? `${agent}: ` : ""}{displayText} + {agent ? `${agent}: ` : ""} + {displayText} {matchedChild && ( @@ -107,4 +140,5 @@ export function isTaskToolPart(part: { type: string; tool?: string }): boolean { return part.type === "tool" && part.tool === "task"; } +export { findMatchingChild }; export type { SubtaskPart }; diff --git a/webview/components/organisms/SubtaskPartView/index.ts b/webview/components/organisms/SubtaskPartView/index.ts index a51c9fe..6487267 100644 --- a/webview/components/organisms/SubtaskPartView/index.ts +++ b/webview/components/organisms/SubtaskPartView/index.ts @@ -1 +1 @@ -export { isTaskToolPart, type SubtaskPart, SubtaskPartView } from "./SubtaskPartView"; +export { findMatchingChild, isTaskToolPart, type SubtaskPart, SubtaskPartView } from "./SubtaskPartView"; From 63a1273ad746e2177b6df7f9aaff0c4c6ee84839 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 06:19:15 +0900 Subject: [PATCH 13/15] test: add findMatchingChild tests and server-format title matching tests --- .../organisms/SubtaskPartView.test.tsx | 150 +++++++++++++++++- .../scenarios/17-child-session-nav.test.tsx | 14 +- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/webview/__tests__/components/organisms/SubtaskPartView.test.tsx b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx index e7ce257..e92d98e 100644 --- a/webview/__tests__/components/organisms/SubtaskPartView.test.tsx +++ b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx @@ -1,7 +1,7 @@ import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { SubtaskPartView, isTaskToolPart } from "../../../components/organisms/SubtaskPartView"; +import { findMatchingChild, isTaskToolPart, SubtaskPartView } from "../../../components/organisms/SubtaskPartView"; import { createSession, createSubtaskPart, createTaskToolPart } from "../../factories"; const defaultProps = { @@ -193,3 +193,151 @@ describe("isTaskToolPart", () => { expect(isTaskToolPart({ type: "subtask" })).toBe(false); }); }); + +describe("findMatchingChild", () => { + // returns undefined when childSessions is empty + context("子セッションが空の場合", () => { + it("undefined を返すこと", () => { + const part = createTaskToolPart("general", "Do something"); + expect(findMatchingChild(part, [], "Do something", "Do something")).toBeUndefined(); + }); + }); + + // matches by metadata.sessionId (Strategy 1) + context("state.metadata.sessionId で直接マッチする場合", () => { + it("metadata の sessionId で子セッションを特定すること", () => { + const childSession = createSession({ id: "child-meta-1", title: "Unrelated title" }); + const part = createTaskToolPart("general", "Some task", { + state: { + status: "completed", + title: "Some task", + input: { subagent_type: "general", description: "Some task", prompt: "Some task" }, + output: "Done", + metadata: { sessionId: "child-meta-1" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + expect(findMatchingChild(part, [childSession], "Some task", "Some task")).toBe(childSession); + }); + + it("metadata.sessionId が childSessions にない場合はフォールバックすること", () => { + const childSession = createSession({ id: "child-2", title: "Some task" }); + const part = createTaskToolPart("general", "Some task", { + state: { + status: "completed", + title: "Some task", + input: { subagent_type: "general", description: "Some task", prompt: "Some task" }, + output: "Done", + metadata: { sessionId: "non-existent-id" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + // Falls back to Strategy 2 (title includes description) + expect(findMatchingChild(part, [childSession], "Some task", "Some task")).toBe(childSession); + }); + }); + + // matches by partial title containing description (Strategy 2 — server appends suffix) + context("子セッション title にサーバー付与のサフィックスがある場合", () => { + it("description が title に含まれる場合マッチすること", () => { + // Server sets title as: "Fix the bug (@coder subagent)" + const childSession = createSession({ id: "child-3", title: "Fix the bug (@coder subagent)" }); + const part = createSubtaskPart("coder", "Fix the bug"); + expect(findMatchingChild(part, [childSession], "Fix the bug", "Fix the bug")).toBe(childSession); + }); + }); + + // matches by prompt when description doesn't match (Strategy 3) + context("description ではマッチせず prompt でマッチする場合", () => { + it("prompt が title に含まれる場合マッチすること", () => { + const childSession = createSession({ id: "child-4", title: "Find critical bugs (@general subagent)" }); + const part = createSubtaskPart("general", "unrelated-desc", { prompt: "Find critical bugs" }); + expect(findMatchingChild(part, [childSession], "unrelated-desc", "Find critical bugs")).toBe(childSession); + }); + }); + + // returns undefined when no strategy matches + context("どの戦略でもマッチしない場合", () => { + it("undefined を返すこと", () => { + const childSession = createSession({ id: "child-5", title: "Completely different task" }); + const part = createSubtaskPart("coder", "Some description", { prompt: "Some prompt" }); + expect(findMatchingChild(part, [childSession], "Some description", "Some prompt")).toBeUndefined(); + }); + }); + + // skips metadata check for pending status + context("task ツールが pending 状態の場合", () => { + it("metadata チェックをスキップして title マッチにフォールバックすること", () => { + const childSession = createSession({ id: "child-6", title: "Pending task (@agent subagent)" }); + const part = createTaskToolPart("agent", "Pending task", { + state: { + status: "pending", + input: {}, + raw: "", + }, + }); + expect(findMatchingChild(part, [childSession], "Pending task", "")).toBe(childSession); + }); + }); + + // metadata takes priority over title match + context("metadata と title の両方でマッチ可能な場合", () => { + it("metadata.sessionId のマッチを優先すること", () => { + const childByTitle = createSession({ id: "child-title", title: "Do X" }); + const childByMeta = createSession({ id: "child-meta", title: "Different" }); + const part = createTaskToolPart("general", "Do X", { + state: { + status: "completed", + title: "Do X", + input: { subagent_type: "general", description: "Do X", prompt: "Do X" }, + output: "Done", + metadata: { sessionId: "child-meta" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + expect(findMatchingChild(part, [childByTitle, childByMeta], "Do X", "Do X")).toBe(childByMeta); + }); + }); +}); + +// Component: navigate with server-format title (suffix appended) +describe("SubtaskPartView — サーバー形式のタイトルマッチング", () => { + context("子セッションの title にサフィックスが付与されている場合", () => { + it("description を含む title にマッチしてナビゲートできること", async () => { + const user = userEvent.setup(); + const childSession = createSession({ id: "child-suffix", title: "Analyze code (@coder subagent)" }); + const part = createTaskToolPart("coder", "Analyze code"); + const onNav = vi.fn(); + const { container } = render( + , + ); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + await user.click(container.querySelector(".header")!); + expect(onNav).toHaveBeenCalledWith("child-suffix"); + }); + }); + + context("metadata.sessionId で子セッションに直接ナビゲートする場合", () => { + it("metadata の sessionId を使って正しい子セッションにナビゲートすること", async () => { + const user = userEvent.setup(); + const childSession = createSession({ id: "child-from-meta", title: "Server assigned title" }); + const part = createTaskToolPart("general", "My task", { + state: { + status: "completed", + title: "My task", + input: { subagent_type: "general", description: "My task", prompt: "details here" }, + output: "ok", + metadata: { sessionId: "child-from-meta" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + const onNav = vi.fn(); + const { container } = render( + , + ); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + await user.click(container.querySelector(".header")!); + expect(onNav).toHaveBeenCalledWith("child-from-meta"); + }); + }); +}); diff --git a/webview/__tests__/scenarios/17-child-session-nav.test.tsx b/webview/__tests__/scenarios/17-child-session-nav.test.tsx index 4c5f60f..7fe9693 100644 --- a/webview/__tests__/scenarios/17-child-session-nav.test.tsx +++ b/webview/__tests__/scenarios/17-child-session-nav.test.tsx @@ -2,13 +2,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage } from "../../vscode-api"; -import { - createMessage, - createSession, - createSubtaskPart, - createTaskToolPart, - createTextPart, -} from "../factories"; +import { createMessage, createSession, createSubtaskPart, createTaskToolPart, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; /** 親セッション + subtask パート + 子セッションをセットアップする */ @@ -151,7 +145,11 @@ describe("子セッションナビゲーション", () => { messages: [{ info: assistantMsg, parts: [textPart, taskToolPart as any] }], }); - const childSession = createSession({ id: "child-task-1", title: "Search for utils", parentID: "parent-2" }); + const childSession = createSession({ + id: "child-task-1", + title: "Search for utils (@general subagent)", + parentID: "parent-2", + }); await sendExtMessage({ type: "childSessions", sessionId: "parent-2", children: [childSession] }); vi.mocked(postMessage).mockClear(); }); From 446e1abc653131b2851e4bf675da0abc745bcfb9 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 06:24:05 +0900 Subject: [PATCH 14/15] style: move agent chip to context bar alongside file chips --- .../organisms/InputArea/InputArea.module.css | 29 +++++++---- .../organisms/InputArea/InputArea.tsx | 50 ++++++++++--------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/webview/components/organisms/InputArea/InputArea.module.css b/webview/components/organisms/InputArea/InputArea.module.css index be96cb8..9fa68cb 100644 --- a/webview/components/organisms/InputArea/InputArea.module.css +++ b/webview/components/organisms/InputArea/InputArea.module.css @@ -23,6 +23,14 @@ padding-bottom: 4px; } +.contextBarLeft { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + min-width: 0; +} + .textareaContainer { position: relative; } @@ -92,24 +100,25 @@ width: fit-content; } -.agentIndicator { - display: flex; +.agentChip { + display: inline-flex; align-items: center; gap: 4px; - padding: 2px 6px; - margin-bottom: 2px; + height: 22px; + padding: 0 6px; font-size: 11px; + line-height: 1; color: var(--vscode-terminal-ansiBlue, #61afef); - background-color: var(--vscode-badge-background); - border-radius: 3px; - width: fit-content; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-terminal-ansiBlue, #61afef); + border-radius: 4px; } -.agentName { +.agentChipName { font-weight: 600; } -.agentClear { +.agentChipClear { display: inline-flex; align-items: center; justify-content: center; @@ -122,6 +131,6 @@ opacity: 0.7; } -.agentClear:hover { +.agentChipClear:hover { opacity: 1; } diff --git a/webview/components/organisms/InputArea/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx index 5d49757..0a2ab81 100644 --- a/webview/components/organisms/InputArea/InputArea.tsx +++ b/webview/components/organisms/InputArea/InputArea.tsx @@ -358,21 +358,33 @@ export function InputArea({ return (
- {/* コンテキストバー: クリップボタン + 添付ファイルチップ + quick-add を1行に */} + {/* コンテキストバー: エージェントチップ + クリップボタン + 添付ファイルチップ + quick-add を1行に */}
- +
+ {/* 選択済みエージェントチップ(ファイルチップの先頭に表示) */} + {selectedAgent && ( +
+ + @{selectedAgent.name} + +
+ )} + +
{/* コンテキストウィンドウ使用率インジケーター (右側) */} {contextLimit > 0 && ( )} - {/* 選択済みエージェントインジケーター */} - {selectedAgent && ( -
- - @{selectedAgent.name} - -
- )}
From 80eafce346d067621f5f500e8722658d0f153cfb Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 06:27:44 +0900 Subject: [PATCH 15/15] style: fix import ordering in MessageItem --- webview/components/organisms/MessageItem/MessageItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview/components/organisms/MessageItem/MessageItem.tsx b/webview/components/organisms/MessageItem/MessageItem.tsx index 5537f29..a37eb11 100644 --- a/webview/components/organisms/MessageItem/MessageItem.tsx +++ b/webview/components/organisms/MessageItem/MessageItem.tsx @@ -8,7 +8,7 @@ import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "../../a import { ShellResultView } from "../../molecules/ShellResultView"; import { TextPartView } from "../../molecules/TextPartView"; import { PermissionView } from "../PermissionView"; -import { type SubtaskPart, SubtaskPartView, isTaskToolPart } from "../SubtaskPartView"; +import { isTaskToolPart, type SubtaskPart, SubtaskPartView } from "../SubtaskPartView"; import { ToolPartView } from "../ToolPartView"; import styles from "./MessageItem.module.css";