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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,7 +41,9 @@ export type ExtToWebviewMessage =
| { type: "locale"; vscodeLanguage: string }
| { type: "modelUpdated"; model: string; default: Record<string, string> }
| { 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 =
Expand All @@ -50,6 +53,7 @@ export type WebviewToExtMessage =
text: string;
model?: { providerID: string; modelID: string };
files?: FileAttachment[];
agent?: string;
}
| { type: "createSession"; title?: string }
| { type: "listSessions" }
Expand Down Expand Up @@ -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" };

Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 45 additions & 3 deletions src/opencode-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from "node:path";
import {
type Agent,
type Config,
createOpencodeClient,
createOpencodeServer,
Expand All @@ -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 = {
Expand Down Expand Up @@ -180,10 +194,14 @@ export class OpenCodeConnection {
text: string,
model?: { providerID: string; modelID: string },
files?: Array<{ filePath: string; fileName: string }>,
agent?: string,
): Promise<void> {
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 基準で絶対パスに変換する。
Expand All @@ -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: {
Expand Down Expand Up @@ -256,6 +280,16 @@ export class OpenCodeConnection {
});
}

// --- Session Children API ---

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

// --- Session Todo API ---

async getSessionTodos(sessionId: string): Promise<Todo[]> {
Expand All @@ -266,6 +300,14 @@ export class OpenCodeConnection {
return response.data!;
}

// --- Agent API ---

async getAgents(): Promise<Agent[]> {
const client = this.requireClient();
const response = await client.app.agents();
return response.data!;
}

// --- Session Diff API ---

async getSessionDiff(sessionId: string): Promise<FileDiff[]> {
Expand Down
96 changes: 71 additions & 25 deletions webview/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,6 +33,8 @@ export function App() {
const [openEditors, setOpenEditors] = useState<FileAttachment[]>([]);
const [workspaceFiles, setWorkspaceFiles] = useState<FileAttachment[]>([]);
const [todos, setTodos] = useState<Todo[]>([]);
const [childSessions, setChildSessions] = useState<Session[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [openCodePaths, setOpenCodePaths] = useState<{
home: string;
config: string;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -177,14 +198,15 @@ 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",
sessionId: session.activeSession.id,
text,
model: prov.selectedModel ?? undefined,
files: files.length > 0 ? files : undefined,
agent,
});
},
[session.activeSession, prov.selectedModel],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -323,6 +362,9 @@ export function App() {
onOpenTerminal: handleOpenTerminal,
localeSetting: locale.localeSetting,
onLocaleSettingChange: locale.handleLocaleSettingChange,
childSessions,
onNavigateToChild: handleNavigateToChild,
onNavigateToParent: handleNavigateToParent,
};

return (
Expand All @@ -333,6 +375,7 @@ export function App() {
activeSession={session.activeSession}
onNewSession={session.handleNewSession}
onToggleSessionList={session.toggleSessionList}
onNavigateToParent={isChildSession ? handleNavigateToParent : undefined}
/>
{session.showSessionList && (
<SessionList
Expand All @@ -358,29 +401,32 @@ export function App() {
{fileChanges.diffs.length > 0 && (
<FileChangesHeader diffs={fileChanges.diffs} onOpenDiffEditor={handleOpenDiffEditor} />
)}
<InputArea
onSend={handleSend}
onShellExecute={handleShellExecute}
onAbort={handleAbort}
isBusy={session.sessionBusy}
providers={prov.providers}
allProvidersData={prov.allProvidersData}
selectedModel={prov.selectedModel}
onModelSelect={prov.handleModelSelect}
openEditors={openEditors}
workspaceFiles={workspaceFiles}
inputTokens={msg.inputTokens}
contextLimit={prov.contextLimit}
onCompress={handleCompress}
isCompressing={!!session.activeSession?.time?.compacting}
prefillText={msg.prefillText}
onPrefillConsumed={msg.consumePrefill}
openCodePaths={openCodePaths}
onOpenConfigFile={handleOpenConfigFile}
onOpenTerminal={handleOpenTerminal}
localeSetting={locale.localeSetting}
onLocaleSettingChange={locale.handleLocaleSettingChange}
/>
{!isChildSession && (
<InputArea
onSend={handleSend}
onShellExecute={handleShellExecute}
onAbort={handleAbort}
isBusy={session.sessionBusy}
providers={prov.providers}
allProvidersData={prov.allProvidersData}
selectedModel={prov.selectedModel}
onModelSelect={prov.handleModelSelect}
openEditors={openEditors}
workspaceFiles={workspaceFiles}
inputTokens={msg.inputTokens}
contextLimit={prov.contextLimit}
onCompress={handleCompress}
isCompressing={!!session.activeSession?.time?.compacting}
prefillText={msg.prefillText}
onPrefillConsumed={msg.consumePrefill}
openCodePaths={openCodePaths}
onOpenConfigFile={handleOpenConfigFile}
onOpenTerminal={handleOpenTerminal}
localeSetting={locale.localeSetting}
onLocaleSettingChange={locale.handleLocaleSettingChange}
agents={agents}
/>
)}
</>
) : (
<EmptyState onNewSession={session.handleNewSession} />
Expand Down
65 changes: 65 additions & 0 deletions webview/__tests__/components/molecules/AgentPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} />,
);
const titles = container.querySelectorAll(".title");
expect(titles[0]?.textContent).toBe("coder");
expect(titles[1]?.textContent).toBe("researcher");
});

// renders agent descriptions
it("エージェントの説明を表示すること", () => {
const { container } = render(
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} />,
);
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(
<AgentPopup agents={agents} onSelectAgent={onSelect} agentPopupRef={{ current: null }} />,
);
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(
<AgentPopup agents={[]} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} />,
);
expect(container.querySelector(".empty")?.textContent).toBe("No agents available");
});
});
});
Loading