diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index 8048696..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, @@ -40,7 +41,9 @@ 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[] } + | { type: "agents"; agents: Agent[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -50,6 +53,7 @@ export type WebviewToExtMessage = text: string; model?: { providerID: string; modelID: string }; files?: FileAttachment[]; + agent?: string; } | { type: "createSession"; title?: string } | { type: "listSessions" } @@ -78,6 +82,8 @@ export type WebviewToExtMessage = | { type: "forkSession"; sessionId: string; messageId?: string } | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } + | { type: "getChildSessions"; sessionId: string } + | { type: "getAgents" } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; @@ -158,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": { @@ -353,6 +359,16 @@ 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 "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 291a5e4..a165b15 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,10 +194,14 @@ 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 }> = - [{ 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 基準で絶対パスに変換する。 @@ -198,6 +216,12 @@ 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: { @@ -256,6 +280,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 { @@ -266,6 +300,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/App.tsx b/webview/App.tsx index d72bd56..4d1de8e 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,4 +1,4 @@ -import type { Event, 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"; @@ -33,6 +33,8 @@ export function App() { const [openEditors, setOpenEditors] = useState([]); const [workspaceFiles, setWorkspaceFiles] = useState([]); const [todos, setTodos] = useState([]); + const [childSessions, setChildSessions] = useState([]); + const [agents, setAgents] = useState([]); const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; @@ -65,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, @@ -94,10 +102,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,11 +164,22 @@ export function App() { } break; } + case "childSessions": { + if (data.sessionId === session.activeSession?.id) { + setChildSessions(data.children); + } + 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, @@ -177,7 +198,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", @@ -185,6 +206,7 @@ export function App() { text, model: prov.selectedModel ?? undefined, files: files.length > 0 ? files : undefined, + agent, }); }, [session.activeSession, prov.selectedModel], @@ -285,6 +307,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 +362,9 @@ export function App() { onOpenTerminal: handleOpenTerminal, localeSetting: locale.localeSetting, onLocaleSettingChange: locale.handleLocaleSettingChange, + childSessions, + onNavigateToChild: handleNavigateToChild, + onNavigateToParent: handleNavigateToParent, }; return ( @@ -333,6 +375,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/__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__/components/organisms/SubtaskPartView.test.tsx b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx new file mode 100644 index 0000000..e92d98e --- /dev/null +++ b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx @@ -0,0 +1,343 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { findMatchingChild, isTaskToolPart, SubtaskPartView } from "../../../components/organisms/SubtaskPartView"; +import { createSession, createSubtaskPart, createTaskToolPart } 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(); + }); + }); + + // 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); + }); +}); + +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__/factories.ts b/webview/__tests__/factories.ts index c1e9225..f8ab4fb 100644 --- a/webview/__tests__/factories.ts +++ b/webview/__tests__/factories.ts @@ -59,6 +59,47 @@ 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 = {}) { + 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..7fe9693 --- /dev/null +++ b/webview/__tests__/scenarios/17-child-session-nav.test.tsx @@ -0,0 +1,181 @@ +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 { 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(); + }); + }); + + // 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 (@general subagent)", + 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", + }); + }); + }); +}); 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..6efe19a --- /dev/null +++ b/webview/__tests__/scenarios/18-agent-mention.test.tsx @@ -0,0 +1,199 @@ +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"; + +/** テスト用エージェントデータ(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" }) }); + + // エージェント一覧を設定(subagent + primary を含む) + await sendExtMessage({ type: "agents", agents: testAgents }); + + 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 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, "@"); + // 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, "@gen"); + expect(screen.getByText("general")).toBeInTheDocument(); + expect(screen.queryByText("explore")).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("general")); + expect(screen.getByText("@general")).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("general")); + 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("general")); + 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("general")); + await user.type(textarea, "Fix the bug"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Fix the bug", + agent: "general", + }), + ); + }); + }); + + // 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(); + }); + }); +}); 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/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/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/InputArea/InputArea.module.css b/webview/components/organisms/InputArea/InputArea.module.css index 3418bf3..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; } @@ -91,3 +99,38 @@ border-radius: 3px; width: fit-content; } + +.agentChip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 6px; + font-size: 11px; + line-height: 1; + color: var(--vscode-terminal-ansiBlue, #61afef); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-terminal-ansiBlue, #61afef); + border-radius: 4px; +} + +.agentChipName { + font-weight: 600; +} + +.agentChipClear { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.agentChipClear:hover { + opacity: 1; +} diff --git a/webview/components/organisms/InputArea/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx index 0ce3b10..0a2ab81 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,24 +349,42 @@ 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 + ? subagents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) + : subagents.slice(0, 10); + return (
- {/* コンテキストバー: クリップボタン + 添付ファイルチップ + quick-add を1行に */} + {/* コンテキストバー: エージェントチップ + クリップボタン + 添付ファイルチップ + quick-add を1行に */}
- +
+ {/* 選択済みエージェントチップ(ファイルチップの先頭に表示) */} + {selectedAgent && ( +
+ + @{selectedAgent.name} + +
+ )} + +
{/* コンテキストウィンドウ使用率インジケーター (右側) */} {contextLimit > 0 && ( )} + {/* @ トリガー エージェント候補ポップアップ */} + {atTrigger.active && ( + + )}
diff --git a/webview/components/organisms/MessageItem/MessageItem.tsx b/webview/components/organisms/MessageItem/MessageItem.tsx index a8c3e66..a37eb11 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 { isTaskToolPart, 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); @@ -150,7 +151,27 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe case "text": return ; case "tool": + // task ツール呼び出しはサブエージェント起動なので SubtaskPartView で表示する + if (isTaskToolPart(part)) { + return ( + + ); + } 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..f93e55c --- /dev/null +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.module.css @@ -0,0 +1,82 @@ +.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); +} + +.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 new file mode 100644 index 0000000..ce7a10b --- /dev/null +++ b/webview/components/organisms/SubtaskPartView/SubtaskPartView.tsx @@ -0,0 +1,144 @@ +import type { Session, ToolPart } from "@opencode-ai/sdk"; +import { useLocale } from "../../../locales"; +import { AgentIcon, ChevronRightIcon, SpinnerIcon } 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; +}; + +/** 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 }; +} + +/** + * 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[]; + onNavigateToChild: (sessionId: string) => void; +}; + +/** + * 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 パートに対応する子セッションを探す。 + const matchedChild = findMatchingChild(part, childSessions, description, prompt); + + const handleClick = () => { + if (matchedChild) { + onNavigateToChild(matchedChild.id); + } + }; + + return ( +
+
+ {isActive ? : } + {t["childSession.agent"]} + + {agent ? `${agent}: ` : ""} + {displayText} + + {matchedChild && ( + + + + )} +
+ {isError && errorMessage && ( +
+
{errorMessage}
+
+ )} +
+ ); +} + +/** task ツール呼び出しかどうかを判定するヘルパー */ +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 new file mode 100644 index 0000000..6487267 --- /dev/null +++ b/webview/components/organisms/SubtaskPartView/index.ts @@ -0,0 +1 @@ +export { findMatchingChild, isTaskToolPart, 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..5459330 100644 --- a/webview/locales/en.ts +++ b/webview/locales/en.ts @@ -107,6 +107,13 @@ 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", + + // 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 0a453ba..b25114e 100644 --- a/webview/locales/ja.ts +++ b/webview/locales/ja.ts @@ -109,4 +109,11 @@ export const ja: typeof en = { "fileChanges.noChanges": "ファイル変更なし", "fileChanges.openDiff": "差分エディタで開く", "fileChanges.toggle": "ファイル変更", + + // ChildSession + "childSession.agent": "エージェント", + "childSession.backToParent": "親セッションに戻る", + + // AgentMention + "input.noAgents": "利用可能なエージェントがありません", }; diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index a07f4ff..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 = { @@ -67,7 +67,9 @@ 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[] } + | { type: "agents"; agents: Agent[] }; // --- Webview → Extension Host --- export type WebviewToExtMessage = @@ -77,6 +79,7 @@ export type WebviewToExtMessage = text: string; model?: { providerID: string; modelID: string }; files?: FileAttachment[]; + agent?: string; } | { type: "createSession"; title?: string } | { type: "listSessions" } @@ -105,6 +108,8 @@ export type WebviewToExtMessage = | { type: "forkSession"; sessionId: string; messageId?: string } | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } + | { type: "getChildSessions"; sessionId: string } + | { type: "getAgents" } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" };