From f23de83c71ddf465d29f874883478181849eb619 Mon Sep 17 00:00:00 2001 From: ktmage Date: Tue, 24 Feb 2026 20:42:16 +0900 Subject: [PATCH 01/84] feat: add visualstudio.com to autoApprove URLs in settings --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index fa7bfbf..966bd15 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,7 +33,8 @@ "https://*.wikipedia.org": true, "https://*.netlify.app": true, "https://*.hotwired.dev": true, - "https://*.opencode.ai": true + "https://*.opencode.ai": true, + "https://*.visualstudio.com": true }, "chat.instructionsFilesLocations": { ".ktmage/instructions": true, From e43f6463550d7c5f1428117b21b88fb5a20eb3ac Mon Sep 17 00:00:00 2001 From: ktmage Date: Tue, 24 Feb 2026 20:45:18 +0900 Subject: [PATCH 02/84] feat: add zipcloud and google to autoApprove URLs in settings --- .vscode/settings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 966bd15..6f341d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,7 +34,9 @@ "https://*.netlify.app": true, "https://*.hotwired.dev": true, "https://*.opencode.ai": true, - "https://*.visualstudio.com": true + "https://*.visualstudio.com": true, + "https://*.zipcloud.ibsnet.co.jp": true, + "https://*.google.com": true }, "chat.instructionsFilesLocations": { ".ktmage/instructions": true, From beb6d5858770bdb9e754ca4e834a1aeb3074ef7e Mon Sep 17 00:00:00 2001 From: ktmage Date: Tue, 24 Feb 2026 21:40:43 +0900 Subject: [PATCH 03/84] feat: add Biome as linter and formatter - Install @biomejs/biome@2.4.4 (exact version) - Create biome.json with project-specific config - space indent, double quotes, semicolons, line width 120 - CSS linting and formatting enabled - VCS integration with .gitignore - Organize imports via assist - Add npm scripts: check, check:fix, format Refs #1 --- biome.json | 45 +++++++++++++ package-lock.json | 168 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 ++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..199629c --- /dev/null +++ b/biome.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, + "css": { + "linter": { + "enabled": true + }, + "formatter": { + "enabled": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/package-lock.json b/package-lock.json index e1fcd67..62b5517 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "opencode-gui", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-gui", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@opencode-ai/sdk": "^1.2.10" }, "devDependencies": { + "@biomejs/biome": "^2.4.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -585,6 +586,169 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz", + "integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.4", + "@biomejs/cli-darwin-x64": "2.4.4", + "@biomejs/cli-linux-arm64": "2.4.4", + "@biomejs/cli-linux-arm64-musl": "2.4.4", + "@biomejs/cli-linux-x64": "2.4.4", + "@biomejs/cli-linux-x64-musl": "2.4.4", + "@biomejs/cli-win32-arm64": "2.4.4", + "@biomejs/cli-win32-x64": "2.4.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz", + "integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz", + "integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz", + "integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz", + "integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz", + "integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz", + "integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz", + "integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz", + "integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", diff --git a/package.json b/package.json index 74dd7d4..bfe1e73 100644 --- a/package.json +++ b/package.json @@ -62,12 +62,16 @@ "build:webview": "vite build", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "vite build --watch", + "check": "biome check .", + "check:fix": "biome check --fix .", + "format": "biome format --write .", "test": "vitest run --config vitest.config.ts", "test:watch": "vitest --config vitest.config.ts", "package": "vsce package", "publish": "vsce publish" }, "devDependencies": { + "@biomejs/biome": "2.4.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", From 261be23bb1d98028eaef7155339bafa699dac969 Mon Sep 17 00:00:00 2001 From: ktmage Date: Tue, 24 Feb 2026 21:48:01 +0900 Subject: [PATCH 04/84] fix: resolve Biome lint and format violations - Configure rule severities in biome.json: - noDuplicateTestHooks: off (false positive in nested describes) - noExplicitAny, noNonNullAssertion, noArrayIndexKey: warn - useKeyWithClickEvents, noStaticElementInteractions: warn - Add aria-hidden="true" to all decorative SVG icons (35 fixes) - Add type="button" to all button elements (24 fixes) - Fix noNonNullAssertedOptionalChain in test files (3 fixes) - Fix noInvalidUseBeforeDeclaration: move handleEvent before useEffect - Add biome-ignore for intentional dangerouslySetInnerHTML usage - Auto-fix formatting and import ordering across all files Result: 0 errors, 73 warnings (all intentionally warn-level) All tests pass (154/154), build succeeds. Refs #1 --- biome.json | 14 +- config.json | 2 +- src/chat-view-provider.ts | 85 +++-- src/extension.ts | 22 +- src/opencode-client.ts | 35 +-- vite.config.ts | 2 +- vitest.config.ts | 2 +- webview/App.tsx | 297 +++++++++--------- webview/__tests__/factories.ts | 10 +- webview/__tests__/helpers.tsx | 4 +- .../scenarios/01-initialization.test.tsx | 36 ++- .../scenarios/02-session-lifecycle.test.tsx | 4 +- .../__tests__/scenarios/03-messaging.test.tsx | 23 +- .../scenarios/04-message-editing.test.tsx | 4 +- .../scenarios/05-permissions.test.tsx | 4 +- .../scenarios/06-model-selection.test.tsx | 12 +- .../scenarios/07-file-context.test.tsx | 22 +- .../scenarios/08-context-compression.test.tsx | 75 ++++- .../__tests__/scenarios/09-settings.test.tsx | 8 +- .../scenarios/10-tool-display.test.tsx | 12 +- webview/__tests__/scenarios/11-todo.test.tsx | 4 +- .../scenarios/12-reasoning-display.test.tsx | 8 +- .../scenarios/13-keyboard-ime.test.tsx | 50 ++- webview/__tests__/setup.ts | 2 +- webview/components/ChatHeader.tsx | 20 +- webview/components/ContextIndicator.tsx | 21 +- webview/components/EmptyState.tsx | 6 +- webview/components/InputArea.tsx | 249 +++++++++------ webview/components/MessageItem.tsx | 91 +++--- webview/components/MessagesArea.tsx | 20 +- webview/components/ModelSelector.tsx | 79 +++-- webview/components/PermissionView.tsx | 8 +- webview/components/SessionList.tsx | 24 +- webview/components/TextPartView.tsx | 11 +- webview/components/TodoHeader.tsx | 23 +- webview/components/ToolConfigPanel.tsx | 15 +- webview/components/ToolPartView.tsx | 80 ++--- webview/styles.css | 14 +- webview/vscode-api.ts | 31 +- 39 files changed, 811 insertions(+), 618 deletions(-) diff --git a/biome.json b/biome.json index 199629c..fb33fb9 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,19 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noDuplicateTestHooks": "off", + "noExplicitAny": "warn", + "noArrayIndexKey": "warn" + }, + "style": { + "noNonNullAssertion": "warn" + }, + "a11y": { + "useKeyWithClickEvents": "warn", + "noStaticElementInteractions": "warn" + } } }, "javascript": { diff --git a/config.json b/config.json index f63d522..7dc84d5 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,4 @@ { "model": "test-provider/test-model", "$schema": "https://opencode.ai/config.json" -} \ No newline at end of file +} diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index 313f52b..e16ea55 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -1,7 +1,16 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import * as vscode from "vscode"; -import { OpenCodeConnection, type Event, type Session, type Message, type Part, type Provider, type OpenCodePath, type ProviderListResult } from "./opencode-client"; -import * as path from "path"; -import * as fs from "fs/promises"; +import type { + Event, + Message, + OpenCodeConnection, + OpenCodePath, + Part, + Provider, + ProviderListResult, + Session, +} from "./opencode-client"; // --- File attachment --- type FileAttachment = { @@ -15,7 +24,13 @@ export type ExtToWebviewMessage = | { type: "messages"; sessionId: string; messages: Array<{ info: Message; parts: Part[] }> } | { type: "event"; event: Event } | { type: "activeSession"; session: Session | null } - | { type: "providers"; providers: Provider[]; allProviders: ProviderListResult; default: Record; configModel?: string } + | { + type: "providers"; + providers: Provider[]; + allProviders: ProviderListResult; + default: Record; + configModel?: string; + } | { type: "openEditors"; files: FileAttachment[] } | { type: "workspaceFiles"; files: FileAttachment[] } | { type: "contextUsage"; usage: { inputTokens: number; contextLimit: number } } @@ -25,7 +40,13 @@ export type ExtToWebviewMessage = // --- Webview → Extension Host --- export type WebviewToExtMessage = - | { type: "sendMessage"; sessionId: string; text: string; model?: { providerID: string; modelID: string }; files?: FileAttachment[] } + | { + type: "sendMessage"; + sessionId: string; + text: string; + model?: { providerID: string; modelID: string }; + files?: FileAttachment[]; + } | { type: "createSession"; title?: string } | { type: "listSessions" } | { type: "selectSession"; sessionId: string } @@ -38,7 +59,14 @@ export type WebviewToExtMessage = | { type: "searchWorkspaceFiles"; query: string } | { type: "compressSession"; sessionId: string; model?: { providerID: string; modelID: string } } | { type: "revertToMessage"; sessionId: string; messageId: string } - | { type: "editAndResend"; sessionId: string; messageId: string; text: string; model?: { providerID: string; modelID: string }; files?: FileAttachment[] } + | { + type: "editAndResend"; + sessionId: string; + messageId: string; + text: string; + model?: { providerID: string; modelID: string }; + files?: FileAttachment[]; + } | { type: "openConfigFile"; filePath: string } | { type: "openTerminal" } | { type: "setModel"; model: string } @@ -66,16 +94,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { webviewView.webview.options = { enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, "dist", "webview"), - ], + localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "dist", "webview")], }; webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); - webviewView.webview.onDidReceiveMessage( - (message: WebviewToExtMessage) => this.handleWebviewMessage(message), - ); + webviewView.webview.onDidReceiveMessage((message: WebviewToExtMessage) => this.handleWebviewMessage(message)); // SSE イベントを Webview に転送する this.connection.onEvent((event) => { @@ -114,7 +138,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } catch { // ファイルが存在しない場合は undefined のまま } - this.postMessage({ type: "providers", providers: providersData.providers, allProviders, default: providersData.default, configModel }); + this.postMessage({ + type: "providers", + providers: providersData.providers, + allProviders, + default: providersData.default, + configModel, + }); this.postMessage({ type: "toolConfig", paths }); break; } @@ -159,11 +189,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { break; } case "replyPermission": { - await this.connection.replyPermission( - message.sessionId, - message.permissionId, - message.response, - ); + await this.connection.replyPermission(message.sessionId, message.permissionId, message.response); break; } case "abort": { @@ -183,7 +209,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } catch { // ignore } - this.postMessage({ type: "providers", providers: providersData.providers, allProviders, default: providersData.default, configModel }); + this.postMessage({ + type: "providers", + providers: providersData.providers, + allProviders, + default: providersData.default, + configModel, + }); break; } case "getOpenEditors": { @@ -248,7 +280,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // ファイルが存在しない場合は初期内容で作成する const dir = vscode.Uri.file(filePath.substring(0, filePath.lastIndexOf("/"))); await vscode.workspace.fs.createDirectory(dir); - await vscode.workspace.fs.writeFile(uri, Buffer.from('{\n "$schema": "https://opencode.ai/config.json"\n}\n')); + await vscode.workspace.fs.writeFile( + uri, + Buffer.from('{\n "$schema": "https://opencode.ai/config.json"\n}\n'), + ); } const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); @@ -282,7 +317,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } configJson.model = message.model; await fs.mkdir(path.dirname(configFilePath), { recursive: true }); - await fs.writeFile(configFilePath, JSON.stringify(configJson, null, 2) + "\n"); + await fs.writeFile(configFilePath, `${JSON.stringify(configJson, null, 2)}\n`); this.postMessage({ type: "modelUpdated", model: message.model, default: {} }); break; } @@ -297,12 +332,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const distUri = vscode.Uri.joinPath(this.extensionUri, "dist", "webview"); // Vite がビルドした JS/CSS アセットを参照する - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(distUri, "assets", "index.js"), - ); - const styleUri = webview.asWebviewUri( - vscode.Uri.joinPath(distUri, "assets", "index.css"), - ); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(distUri, "assets", "index.js")); + const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(distUri, "assets", "index.css")); const nonce = getNonce(); diff --git a/src/extension.ts b/src/extension.ts index a1a7add..b103be6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { OpenCodeConnection } from "./opencode-client"; import { ChatViewProvider } from "./chat-view-provider"; +import { OpenCodeConnection } from "./opencode-client"; const connection = new OpenCodeConnection(); @@ -10,9 +10,7 @@ process.on("exit", () => connection.disconnect()); export async function activate(context: vscode.ExtensionContext) { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!workspaceFolder) { - vscode.window.showWarningMessage( - vscode.l10n.t("OpenCodeGUI requires an open workspace folder."), - ); + vscode.window.showWarningMessage(vscode.l10n.t("OpenCodeGUI requires an open workspace folder.")); return; } @@ -26,12 +24,11 @@ export async function activate(context: vscode.ExtensionContext) { } catch (error) { const isNotFound = error instanceof Error && - ("code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" || - error.message.includes("ENOENT")); + (("code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") || error.message.includes("ENOENT")); if (isNotFound) { vscode.window.showWarningMessage( vscode.l10n.t( - "OpenCodeGUI: \"opencode\" command not found. Please install OpenCode first: https://github.com/opencode-ai/opencode", + 'OpenCodeGUI: "opencode" command not found. Please install OpenCode first: https://github.com/opencode-ai/opencode', ), ); return; @@ -42,16 +39,9 @@ export async function activate(context: vscode.ExtensionContext) { } const chatViewProvider = new ChatViewProvider(context.extensionUri, connection); - context.subscriptions.push( - vscode.window.registerWebviewViewProvider( - ChatViewProvider.viewType, - chatViewProvider, - ), - ); + context.subscriptions.push(vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, chatViewProvider)); - context.subscriptions.push( - new vscode.Disposable(() => connection.disconnect()), - ); + context.subscriptions.push(new vscode.Disposable(() => connection.disconnect())); } export function deactivate() { diff --git a/src/opencode-client.ts b/src/opencode-client.ts index 18bb7e9..e48bc7e 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -1,17 +1,17 @@ -import * as path from "path"; +import * as path from "node:path"; import { + type Config, createOpencodeClient, createOpencodeServer, - type OpencodeClient, type Event, - type Session, + type McpStatus, type Message, + type Path as OpenCodePath, + type OpencodeClient, type Part, type Provider, - type McpStatus, + type Session, type ToolListItem, - type Config, - type Path as OpenCodePath, } from "@opencode-ai/sdk"; import * as vscode from "vscode"; @@ -152,9 +152,7 @@ export class OpenCodeConnection { // --- Message API --- - async getMessages( - sessionId: string, - ): Promise> { + async getMessages(sessionId: string): Promise> { const client = this.requireClient(); const response = await client.session.messages({ path: { id: sessionId }, @@ -173,9 +171,8 @@ export class OpenCodeConnection { files?: Array<{ filePath: string; fileName: 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: "text", text }]; if (files) { for (const file of files) { // filePath はワークスペース相対パス。cwd 基準で絶対パスに変換する。 @@ -236,10 +233,7 @@ export class OpenCodeConnection { // --- Revert API --- - async revertSession( - sessionId: string, - messageID: string, - ): Promise { + async revertSession(sessionId: string, messageID: string): Promise { const client = this.requireClient(); const response = await client.session.revert({ path: { id: sessionId }, @@ -250,10 +244,7 @@ export class OpenCodeConnection { // --- Summarize API --- - async summarizeSession( - sessionId: string, - model?: { providerID: string; modelID: string }, - ): Promise { + async summarizeSession(sessionId: string, model?: { providerID: string; modelID: string }): Promise { const client = this.requireClient(); await client.session.summarize({ path: { id: sessionId }, @@ -321,9 +312,7 @@ export class OpenCodeConnection { private requireClient(): OpencodeClient { if (!this.client) { - throw new Error( - "OpenCode client is not connected. Call connect() first.", - ); + throw new Error("OpenCode client is not connected. Call connect() first."); } return this.client; } diff --git a/vite.config.ts b/vite.config.ts index cbf92b9..9481a14 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], diff --git a/vitest.config.ts b/vitest.config.ts index 4b9430f..f19162e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], diff --git a/webview/App.tsx b/webview/App.tsx index 320ab4f..f7eed0f 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,17 +1,17 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import type { Session, Message, Part, Event, Permission, Provider } from "@opencode-ai/sdk"; -import type { ExtToWebviewMessage, FileAttachment, AllProvidersData } from "./vscode-api"; -import { postMessage, getPersistedState, setPersistedState } from "./vscode-api"; +import type { Event, Message, Part, Permission, Provider, Session } from "@opencode-ai/sdk"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ChatHeader } from "./components/ChatHeader"; -import { MessagesArea } from "./components/MessagesArea"; -import { InputArea } from "./components/InputArea"; import { EmptyState } from "./components/EmptyState"; +import { InputArea } from "./components/InputArea"; +import { MessagesArea } from "./components/MessagesArea"; import { SessionList } from "./components/SessionList"; import { TodoHeader } from "./components/TodoHeader"; -import { parseTodos } from "./components/ToolPartView"; import type { TodoItem } from "./components/ToolPartView"; -import { LocaleProvider, resolveLocale, getStrings } from "./locales"; +import { parseTodos } from "./components/ToolPartView"; import type { LocaleSetting } from "./locales"; +import { getStrings, LocaleProvider, resolveLocale } from "./locales"; +import type { AllProvidersData, ExtToWebviewMessage, FileAttachment } from "./vscode-api"; +import { getPersistedState, postMessage, setPersistedState } from "./vscode-api"; export type MessageWithParts = { info: Message; parts: Part[] }; @@ -31,7 +31,12 @@ export function App() { // チェックポイントからの復元時にテキストを入力欄にプリフィルするためのステート const [prefillText, setPrefillText] = useState(""); // 設定パネル用のステート - const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; state: string; directory: string } | null>(null); + const [openCodePaths, setOpenCodePaths] = useState<{ + home: string; + config: string; + state: string; + directory: string; + } | null>(null); // ロケール管理 const [localeSetting, setLocaleSetting] = useState( @@ -68,6 +73,86 @@ export function App() { return model?.limit?.context ?? 0; }, [providers, selectedModel]); + const handleEvent = useCallback((event: Event) => { + switch (event.type) { + case "message.updated": { + const info = event.properties.info; + setMessages((prev) => { + const idx = prev.findIndex((m) => m.info.id === info.id); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], info }; + return updated; + } + return [...prev, { info, parts: [] }]; + }); + break; + } + case "message.part.updated": { + const part = event.properties.part; + setMessages((prev) => { + const idx = prev.findIndex((m) => m.info.id === part.messageID); + if (idx < 0) return prev; + const updated = [...prev]; + const msg = { ...updated[idx] }; + const partIdx = msg.parts.findIndex((p) => p.id === part.id); + if (partIdx >= 0) { + msg.parts = [...msg.parts]; + msg.parts[partIdx] = part; + } else { + msg.parts = [...msg.parts, part]; + } + updated[idx] = msg; + return updated; + }); + break; + } + case "session.status": { + setSessionBusy(event.properties.status.type === "busy"); + break; + } + case "permission.updated": { + const permission = event.properties; + setPermissions((prev) => { + const next = new Map(prev); + next.set(permission.id, permission); + return next; + }); + break; + } + case "permission.replied": { + const permissionID = event.properties.permissionID; + setPermissions((prev) => { + const next = new Map(prev); + next.delete(permissionID); + return next; + }); + break; + } + case "message.removed": { + // 圧縮時にメッセージが削除される → messages から除去すると inputTokens も自動的に再計算される + const { messageID } = event.properties; + setMessages((prev) => prev.filter((m) => m.info.id !== messageID)); + break; + } + case "session.updated": { + const info = event.properties.info; + setSessions((prev) => prev.map((s) => (s.id === info.id ? info : s))); + setActiveSession((prev) => (prev?.id === info.id ? info : prev)); + break; + } + case "session.created": { + setSessions((prev) => [event.properties.info, ...prev]); + break; + } + case "session.deleted": { + const deletedId = event.properties.info.id; + setSessions((prev) => prev.filter((s) => s.id !== deletedId)); + break; + } + } + }, []); + useEffect(() => { const handler = (e: MessageEvent) => { const msg = e.data; @@ -96,7 +181,8 @@ export function App() { setAllProvidersData(msg.allProviders); // サーバーのモデル設定を反映する(config.model → default の順でフォールバック) setSelectedModel(() => { - const modelStr = msg.configModel || msg.default["general"] || msg.default["code"] || Object.values(msg.default)[0]; + const modelStr = + msg.configModel || msg.default.general || msg.default.code || Object.values(msg.default)[0]; if (!modelStr) return null; const slashIndex = modelStr.indexOf("/"); if (slashIndex < 0) return null; @@ -135,94 +221,7 @@ export function App() { postMessage({ type: "ready" }); postMessage({ type: "getOpenEditors" }); return () => window.removeEventListener("message", handler); - }, [activeSession?.id]); - - const handleEvent = useCallback( - (event: Event) => { - switch (event.type) { - case "message.updated": { - const info = event.properties.info; - setMessages((prev) => { - const idx = prev.findIndex((m) => m.info.id === info.id); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { ...updated[idx], info }; - return updated; - } - return [...prev, { info, parts: [] }]; - }); - break; - } - case "message.part.updated": { - const part = event.properties.part; - setMessages((prev) => { - const idx = prev.findIndex((m) => m.info.id === part.messageID); - if (idx < 0) return prev; - const updated = [...prev]; - const msg = { ...updated[idx] }; - const partIdx = msg.parts.findIndex((p) => p.id === part.id); - if (partIdx >= 0) { - msg.parts = [...msg.parts]; - msg.parts[partIdx] = part; - } else { - msg.parts = [...msg.parts, part]; - } - updated[idx] = msg; - return updated; - }); - break; - } - case "session.status": { - setSessionBusy(event.properties.status.type === "busy"); - break; - } - case "permission.updated": { - const permission = event.properties; - setPermissions((prev) => { - const next = new Map(prev); - next.set(permission.id, permission); - return next; - }); - break; - } - case "permission.replied": { - const permissionID = event.properties.permissionID; - setPermissions((prev) => { - const next = new Map(prev); - next.delete(permissionID); - return next; - }); - break; - } - case "message.removed": { - // 圧縮時にメッセージが削除される → messages から除去すると inputTokens も自動的に再計算される - const { messageID } = event.properties; - setMessages((prev) => prev.filter((m) => m.info.id !== messageID)); - break; - } - case "session.updated": { - const info = event.properties.info; - setSessions((prev) => - prev.map((s) => (s.id === info.id ? info : s)), - ); - setActiveSession((prev) => - prev?.id === info.id ? info : prev, - ); - break; - } - case "session.created": { - setSessions((prev) => [event.properties.info, ...prev]); - break; - } - case "session.deleted": { - const deletedId = event.properties.info.id; - setSessions((prev) => prev.filter((s) => s.id !== deletedId)); - break; - } - } - }, - [], - ); + }, [activeSession?.id, handleEvent]); const handleSend = useCallback( (text: string, files: FileAttachment[]) => { @@ -354,59 +353,59 @@ export function App() { return ( -
- setShowSessionList((s) => !s)} - /> - {showSessionList && ( - setShowSessionList(false)} +
+ setShowSessionList((s) => !s)} /> - )} - {activeSession ? ( - <> - - {latestTodos.length > 0 && } - setPrefillText("")} - openCodePaths={openCodePaths} - onOpenConfigFile={handleOpenConfigFile} - onOpenTerminal={handleOpenTerminal} - localeSetting={localeSetting} - onLocaleSettingChange={handleLocaleSettingChange} + {showSessionList && ( + setShowSessionList(false)} /> - - ) : ( - - )} -
+ )} + {activeSession ? ( + <> + + {latestTodos.length > 0 && } + setPrefillText("")} + openCodePaths={openCodePaths} + onOpenConfigFile={handleOpenConfigFile} + onOpenTerminal={handleOpenTerminal} + localeSetting={localeSetting} + onLocaleSettingChange={handleLocaleSettingChange} + /> + + ) : ( + + )} +
); } diff --git a/webview/__tests__/factories.ts b/webview/__tests__/factories.ts index c87a526..c1e9225 100644 --- a/webview/__tests__/factories.ts +++ b/webview/__tests__/factories.ts @@ -1,4 +1,4 @@ -import type { Session, Message, Part, TextPart, ToolPart, Permission, Provider, Event } from "@opencode-ai/sdk"; +import type { Event, Message, Permission, Provider, Session, TextPart, ToolPart } from "@opencode-ai/sdk"; // --- Session --- @@ -73,7 +73,13 @@ export function createPermission(overrides: Partial = {}): Permissio // --- Provider --- -export function createProvider(id: string, models: Record = {}): Provider { +export function createProvider( + id: string, + models: Record< + string, + { id: string; name: string; limit?: { context: number; output: number }; status?: string } + > = {}, +): Provider { return { id, name: id.charAt(0).toUpperCase() + id.slice(1), diff --git a/webview/__tests__/helpers.tsx b/webview/__tests__/helpers.tsx index 49394a0..2496f42 100644 --- a/webview/__tests__/helpers.tsx +++ b/webview/__tests__/helpers.tsx @@ -1,6 +1,6 @@ -import { render, act } from "@testing-library/react"; -import type { ExtToWebviewMessage } from "../vscode-api"; +import { act, render } from "@testing-library/react"; import { App } from "../App"; +import type { ExtToWebviewMessage } from "../vscode-api"; /** * Extension Host → Webview メッセージを擬似送信する。 diff --git a/webview/__tests__/scenarios/01-initialization.test.tsx b/webview/__tests__/scenarios/01-initialization.test.tsx index 09d9201..0c4e6ad 100644 --- a/webview/__tests__/scenarios/01-initialization.test.tsx +++ b/webview/__tests__/scenarios/01-initialization.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; import { postMessage } from "../../vscode-api"; +import { createAllProvidersData, createProvider, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createProvider, createAllProvidersData } from "../factories"; // Initialization describe("初期化", () => { @@ -61,9 +61,22 @@ describe("初期化", () => { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { + id: "claude-4-opus", + name: "Claude 4 Opus", + limit: { context: 200000, output: 4096 }, + }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); @@ -87,9 +100,16 @@ describe("初期化", () => { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["openai"], [ - { id: "openai", name: "OpenAI", models: { "gpt-5": { id: "gpt-5", name: "GPT-5", limit: { context: 128000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["openai"], + [ + { + id: "openai", + name: "OpenAI", + models: { "gpt-5": { id: "gpt-5", name: "GPT-5", limit: { context: 128000, output: 4096 } } }, + }, + ], + ), default: { general: "openai/gpt-5" }, }); diff --git a/webview/__tests__/scenarios/02-session-lifecycle.test.tsx b/webview/__tests__/scenarios/02-session-lifecycle.test.tsx index 93754c7..3e524c9 100644 --- a/webview/__tests__/scenarios/02-session-lifecycle.test.tsx +++ b/webview/__tests__/scenarios/02-session-lifecycle.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage, createTextPart } from "../factories"; /** * セッションを持つ初期状態をセットアップするヘルパー。 diff --git a/webview/__tests__/scenarios/03-messaging.test.tsx b/webview/__tests__/scenarios/03-messaging.test.tsx index c228d86..d78cbf7 100644 --- a/webview/__tests__/scenarios/03-messaging.test.tsx +++ b/webview/__tests__/scenarios/03-messaging.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage, createTextPart } from "../factories"; /** アクティブセッションを持つ状態をセットアップする */ async function setupActiveSession() { @@ -41,9 +41,7 @@ describe("メッセージング", () => { const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); await user.type(textarea, "{Enter}"); - expect(postMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "sendMessage" }), - ); + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" })); }); // Received messages are displayed @@ -287,9 +285,18 @@ describe("メッセージング", () => { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); diff --git a/webview/__tests__/scenarios/04-message-editing.test.tsx b/webview/__tests__/scenarios/04-message-editing.test.tsx index 6f351e6..ee39c01 100644 --- a/webview/__tests__/scenarios/04-message-editing.test.tsx +++ b/webview/__tests__/scenarios/04-message-editing.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage, createTextPart } from "../factories"; /** ユーザー→アシスタント→ユーザーの3メッセージ構成をセットアップする */ async function setupConversation() { diff --git a/webview/__tests__/scenarios/05-permissions.test.tsx b/webview/__tests__/scenarios/05-permissions.test.tsx index f434f45..d924b85 100644 --- a/webview/__tests__/scenarios/05-permissions.test.tsx +++ b/webview/__tests__/scenarios/05-permissions.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage, createTextPart, createPermission } from "../factories"; /** パーミッション付きのアクティブセッションをセットアップする */ async function setupWithPermission() { diff --git a/webview/__tests__/scenarios/06-model-selection.test.tsx b/webview/__tests__/scenarios/06-model-selection.test.tsx index 7df2b5e..a235afb 100644 --- a/webview/__tests__/scenarios/06-model-selection.test.tsx +++ b/webview/__tests__/scenarios/06-model-selection.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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 { createAllProvidersData, createProvider, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createProvider, createAllProvidersData } from "../factories"; /** プロバイダー付きアクティブセッションをセットアップする */ async function setupWithProviders() { @@ -13,7 +13,7 @@ async function setupWithProviders() { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, "claude-4-sonnet": { id: "claude-4-sonnet", name: "Claude 4 Sonnet", limit: { context: 200000, output: 4096 } }, }); - const openai = createProvider("openai", { + const _openai = createProvider("openai", { "gpt-5": { id: "gpt-5", name: "GPT-5", limit: { context: 128000, output: 4096 } }, }); @@ -25,7 +25,11 @@ async function setupWithProviders() { name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, - "claude-4-sonnet": { id: "claude-4-sonnet", name: "Claude 4 Sonnet", limit: { context: 200000, output: 4096 } }, + "claude-4-sonnet": { + id: "claude-4-sonnet", + name: "Claude 4 Sonnet", + limit: { context: 200000, output: 4096 }, + }, }, }, { diff --git a/webview/__tests__/scenarios/07-file-context.test.tsx b/webview/__tests__/scenarios/07-file-context.test.tsx index 4af78df..8c0ab7d 100644 --- a/webview/__tests__/scenarios/07-file-context.test.tsx +++ b/webview/__tests__/scenarios/07-file-context.test.tsx @@ -1,10 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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 { renderApp, sendExtMessage } from "../helpers"; import { createSession } from "../factories"; -import type { FileAttachment } from "../../vscode-api"; +import { renderApp, sendExtMessage } from "../helpers"; /** ファイルコンテキストテスト用のアクティブセッションをセットアップする */ async function setupWithFiles() { @@ -52,8 +51,9 @@ describe("ファイルコンテキスト", () => { const user = userEvent.setup(); await user.click(screen.getByTitle("Add context")); const items = screen.getAllByText("main.ts"); - const pickerItem = items.find((el) => el.closest(".file-picker-item")); - await user.click(pickerItem!.closest(".file-picker-item")!); + const clickTarget = items.find((el) => el.closest(".file-picker-item"))?.closest(".file-picker-item"); + if (!clickTarget) throw new Error("file-picker-item not found"); + await user.click(clickTarget); }); // Chip is shown @@ -76,8 +76,9 @@ describe("ファイルコンテキスト", () => { // ファイルを添付 await user.click(screen.getByTitle("Add context")); const items = screen.getAllByText("main.ts"); - const pickerItem = items.find((el) => el.closest(".file-picker-item")); - await user.click(pickerItem!.closest(".file-picker-item")!); + const clickTarget = items.find((el) => el.closest(".file-picker-item"))?.closest(".file-picker-item"); + if (!clickTarget) throw new Error("file-picker-item not found"); + await user.click(clickTarget); // チップが表示される expect(document.querySelectorAll(".attached-file-chip").length).toBe(1); @@ -126,7 +127,7 @@ describe("ファイルコンテキスト", () => { textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); await user.type(textarea, "Look at #"); const popup = document.querySelector(".hash-popup"); - const popupItem = popup!.querySelector(".hash-popup-item"); + const popupItem = popup?.querySelector(".hash-popup-item"); await user.click(popupItem!); }); @@ -149,8 +150,9 @@ describe("ファイルコンテキスト", () => { // ファイルを添付 await user.click(screen.getByTitle("Add context")); const items = screen.getAllByText("main.ts"); - const pickerItem = items.find((el) => el.closest(".file-picker-item")); - await user.click(pickerItem!.closest(".file-picker-item")!); + const clickTarget = items.find((el) => el.closest(".file-picker-item"))?.closest(".file-picker-item"); + if (!clickTarget) throw new Error("file-picker-item not found"); + await user.click(clickTarget); // メッセージ送信 const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); diff --git a/webview/__tests__/scenarios/08-context-compression.test.tsx b/webview/__tests__/scenarios/08-context-compression.test.tsx index fcb0094..5ac9e14 100644 --- a/webview/__tests__/scenarios/08-context-compression.test.tsx +++ b/webview/__tests__/scenarios/08-context-compression.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; 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 { createAllProvidersData, createMessage, createProvider, createSession, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage, createTextPart, createToolPart, createProvider, createAllProvidersData } from "../factories"; /** step-finish パート付きメッセージをセットアップする */ async function setupWithTokenUsage() { @@ -16,9 +16,18 @@ async function setupWithTokenUsage() { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); @@ -73,7 +82,7 @@ describe("コンテキストとコンプレッション", () => { // Compress button sends compressSession it("圧縮ボタンで compressSession が送信されること", async () => { - const session = await setupWithTokenUsage(); + const _session = await setupWithTokenUsage(); const user = userEvent.setup(); // ContextIndicator をクリックしてポップアップを開く @@ -122,9 +131,18 @@ describe("コンテキストとコンプレッション", () => { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); @@ -146,9 +164,18 @@ describe("コンテキストとコンプレッション", () => { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 100000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 100000, output: 4096 } }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); @@ -214,14 +241,30 @@ describe("コンテキストとコンプレッション", () => { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { + id: "claude-4-opus", + name: "Claude 4 Opus", + limit: { context: 200000, output: 4096 }, + }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); - const session = createSession({ id: "s1", time: { created: Date.now(), updated: Date.now(), compacting: Date.now() } } as any); + const session = createSession({ + id: "s1", + time: { created: Date.now(), updated: Date.now(), compacting: Date.now() }, + } as any); await sendExtMessage({ type: "activeSession", session }); const msg = createMessage({ id: "m1", sessionID: "s1", role: "assistant" }); diff --git a/webview/__tests__/scenarios/09-settings.test.tsx b/webview/__tests__/scenarios/09-settings.test.tsx index a6a8ac9..2f3b5b4 100644 --- a/webview/__tests__/scenarios/09-settings.test.tsx +++ b/webview/__tests__/scenarios/09-settings.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage, setPersistedState } from "../../vscode-api"; -import { renderApp, sendExtMessage } from "../helpers"; import { createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; /** 設定パネル用のアクティブセッションをセットアップする */ async function setupForSettings() { @@ -50,9 +50,7 @@ describe("設定", () => { // 「日本語」ラジオボタンをクリック await user.click(screen.getByText("日本語")); - expect(setPersistedState).toHaveBeenCalledWith( - expect.objectContaining({ localeSetting: "ja" }), - ); + expect(setPersistedState).toHaveBeenCalledWith(expect.objectContaining({ localeSetting: "ja" })); }); // Config file link sends openConfigFile diff --git a/webview/__tests__/scenarios/10-tool-display.test.tsx b/webview/__tests__/scenarios/10-tool-display.test.tsx index 0878f52..8e64be2 100644 --- a/webview/__tests__/scenarios/10-tool-display.test.tsx +++ b/webview/__tests__/scenarios/10-tool-display.test.tsx @@ -1,9 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createMessage, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage } from "../factories"; -import type { ToolPart } from "@opencode-ai/sdk"; /** ツール表示テスト用のセットアップ。指定した toolPart を持つアシスタントメッセージを表示する。 */ async function setupWithToolPart(toolPart: unknown) { @@ -29,7 +28,12 @@ describe("ツール表示", () => { tool: "read", messageID: "m1", time: { created: Date.now(), updated: Date.now() }, - state: { status: "completed", title: "src/main.ts", input: { filePath: "src/main.ts" }, output: "file content" }, + state: { + status: "completed", + title: "src/main.ts", + input: { filePath: "src/main.ts" }, + output: "file content", + }, }); }); diff --git a/webview/__tests__/scenarios/11-todo.test.tsx b/webview/__tests__/scenarios/11-todo.test.tsx index 36fc76d..fc288cc 100644 --- a/webview/__tests__/scenarios/11-todo.test.tsx +++ b/webview/__tests__/scenarios/11-todo.test.tsx @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createMessage, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage } from "../factories"; /** todowrite ツール出力付きのセッションをセットアップする */ async function setupWithTodos(todos: Array<{ content: string; status: string; priority?: string }>) { diff --git a/webview/__tests__/scenarios/12-reasoning-display.test.tsx b/webview/__tests__/scenarios/12-reasoning-display.test.tsx index ec2aa3e..08425ca 100644 --- a/webview/__tests__/scenarios/12-reasoning-display.test.tsx +++ b/webview/__tests__/scenarios/12-reasoning-display.test.tsx @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createMessage, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createMessage } from "../factories"; /** Reasoning パートを持つメッセージを表示するセットアップ */ async function setupWithReasoningPart(partOverrides: Record = {}) { @@ -49,7 +49,7 @@ describe("思考表示(ReasoningPartView)", () => { // Shows spinner it("スピナーが表示されること", () => { - expect(part!.querySelector(".tool-part-spinner")).toBeInTheDocument(); + expect(part?.querySelector(".tool-part-spinner")).toBeInTheDocument(); }); }); @@ -74,7 +74,7 @@ describe("思考表示(ReasoningPartView)", () => { // No spinner it("スピナーが表示されないこと", () => { - expect(part!.querySelector(".tool-part-spinner")).not.toBeInTheDocument(); + expect(part?.querySelector(".tool-part-spinner")).not.toBeInTheDocument(); }); }); diff --git a/webview/__tests__/scenarios/13-keyboard-ime.test.tsx b/webview/__tests__/scenarios/13-keyboard-ime.test.tsx index 4043d66..2e37296 100644 --- a/webview/__tests__/scenarios/13-keyboard-ime.test.tsx +++ b/webview/__tests__/scenarios/13-keyboard-ime.test.tsx @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen, fireEvent } from "@testing-library/react"; +import { fireEvent, 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 { createAllProvidersData, createProvider, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; -import { createSession, createProvider, createAllProvidersData } from "../factories"; /** アクティブセッション + プロバイダ付きで InputArea が操作可能な状態にする */ async function setupInputReady() { @@ -14,9 +14,18 @@ async function setupInputReady() { await sendExtMessage({ type: "providers", providers: [provider], - allProviders: createAllProvidersData(["anthropic"], [ - { id: "anthropic", name: "Anthropic", models: { "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } } } }, - ]), + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }, + }, + ], + ), default: { general: "anthropic/claude-4-opus" }, configModel: "anthropic/claude-4-opus", }); @@ -44,9 +53,11 @@ describe("キーボード・IME ハンドリング", () => { fireEvent.compositionEnd(textarea); // sendMessage が呼ばれていないことを確認 - const sendCalls = vi.mocked(postMessage).mock.calls.filter( - (call) => call[0] && typeof call[0] === "object" && "type" in call[0] && call[0].type === "sendMessage", - ); + const sendCalls = vi + .mocked(postMessage) + .mock.calls.filter( + (call) => call[0] && typeof call[0] === "object" && "type" in call[0] && call[0].type === "sendMessage", + ); expect(sendCalls).toHaveLength(0); }); @@ -63,9 +74,11 @@ describe("キーボード・IME ハンドリング", () => { // Does not send it("sendMessage が呼ばれないこと", () => { - const sendCalls = vi.mocked(postMessage).mock.calls.filter( - (call) => call[0] && typeof call[0] === "object" && "type" in call[0] && call[0].type === "sendMessage", - ); + const sendCalls = vi + .mocked(postMessage) + .mock.calls.filter( + (call) => call[0] && typeof call[0] === "object" && "type" in call[0] && call[0].type === "sendMessage", + ); expect(sendCalls).toHaveLength(0); }); @@ -86,15 +99,20 @@ describe("キーボード・IME ハンドリング", () => { await user.type(textarea, "test message"); // isBusy 状態にする: session.status busy を送る - await sendExtMessage({ type: "event", event: { type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } } as any }); + await sendExtMessage({ + type: "event", + event: { type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } } as any, + }); // Enter を押す await user.keyboard("{Enter}"); // sendMessage が呼ばれていないことを確認 - const sendCalls = vi.mocked(postMessage).mock.calls.filter( - (call) => call[0] && typeof call[0] === "object" && "type" in call[0] && call[0].type === "sendMessage", - ); + const sendCalls = vi + .mocked(postMessage) + .mock.calls.filter( + (call) => call[0] && typeof call[0] === "object" && "type" in call[0] && call[0].type === "sendMessage", + ); expect(sendCalls).toHaveLength(0); }); }); diff --git a/webview/__tests__/setup.ts b/webview/__tests__/setup.ts index ae084b7..83d6133 100644 --- a/webview/__tests__/setup.ts +++ b/webview/__tests__/setup.ts @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { vi, beforeEach, describe } from "vitest"; +import { beforeEach, describe, vi } from "vitest"; // --- RSpec スタイルの context エイリアス --- // ネストされた describe を意味的に区別するために使用する。 diff --git a/webview/components/ChatHeader.tsx b/webview/components/ChatHeader.tsx index 5a890fd..8573b10 100644 --- a/webview/components/ChatHeader.tsx +++ b/webview/components/ChatHeader.tsx @@ -11,27 +11,17 @@ export function ChatHeader({ activeSession, onNewSession, onToggleSessionList }: const t = useLocale(); return (
- - - {activeSession?.title || t["header.title.fallback"]} - + {activeSession?.title || t["header.title.fallback"]}
- diff --git a/webview/components/ContextIndicator.tsx b/webview/components/ContextIndicator.tsx index d44cd1f..5d2d5b9 100644 --- a/webview/components/ContextIndicator.tsx +++ b/webview/components/ContextIndicator.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useLocale } from "../locales"; type Props = { @@ -51,19 +51,17 @@ export function ContextIndicator({ inputTokens, contextLimit, onCompress, isComp const dashOffset = circumference * (1 - ratio); // 使用率に応じた色 - const color = - percent >= 80 - ? "var(--vscode-editorWarning-foreground)" - : "var(--vscode-textLink-foreground)"; + const color = percent >= 80 ? "var(--vscode-editorWarning-foreground)" : "var(--vscode-textLink-foreground)"; return (
-
+
diff --git a/webview/components/InputArea.tsx b/webview/components/InputArea.tsx index 276675b..2c6c952 100644 --- a/webview/components/InputArea.tsx +++ b/webview/components/InputArea.tsx @@ -1,13 +1,12 @@ -import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type CompositionEvent } from "react"; import type { Provider } from "@opencode-ai/sdk"; -import type { FileAttachment, AllProvidersData } from "../vscode-api"; -import { postMessage } from "../vscode-api"; -import { useLocale } from "../locales"; +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; import type { LocaleSetting } from "../locales"; -import { ModelSelector } from "./ModelSelector"; +import { useLocale } from "../locales"; +import type { AllProvidersData, FileAttachment } from "../vscode-api"; +import { postMessage } from "../vscode-api"; import { ContextIndicator } from "./ContextIndicator"; +import { ModelSelector } from "./ModelSelector"; import { ToolConfigPanel } from "./ToolConfigPanel"; -import type { McpStatus } from "@opencode-ai/sdk"; type Props = { onSend: (text: string, files: FileAttachment[]) => void; @@ -33,11 +32,26 @@ type Props = { }; export function InputArea({ - onSend, onAbort, isBusy, providers, allProvidersData, selectedModel, onModelSelect, - openEditors, workspaceFiles, inputTokens, contextLimit, onCompress, isCompressing, - prefillText, onPrefillConsumed, - openCodePaths, onOpenConfigFile, onOpenTerminal, - localeSetting, onLocaleSettingChange, + onSend, + onAbort, + isBusy, + providers, + allProvidersData, + selectedModel, + onModelSelect, + openEditors, + workspaceFiles, + inputTokens, + contextLimit, + onCompress, + isCompressing, + prefillText, + onPrefillConsumed, + openCodePaths, + onOpenConfigFile, + onOpenTerminal, + localeSetting, + onLocaleSettingChange, }: Props) { const t = useLocale(); const [text, setText] = useState(""); @@ -46,7 +60,10 @@ export function InputArea({ const [filePickerQuery, setFilePickerQuery] = useState(""); const [showToolConfig, setShowToolConfig] = useState(false); // # トリガー用 - const [hashTrigger, setHashTrigger] = useState<{ active: boolean; startIndex: number }>({ active: false, startIndex: -1 }); + const [hashTrigger, setHashTrigger] = useState<{ active: boolean; startIndex: number }>({ + active: false, + startIndex: -1, + }); const [hashQuery, setHashQuery] = useState(""); const textareaRef = useRef(null); const composingRef = useRef(false); @@ -68,7 +85,7 @@ export function InputArea({ } }); } - }, [prefillText]); + }, [prefillText, onPrefillConsumed]); // クリップボタンを押したときにエディタ一覧を取得してファイルピッカーを開く const handleClipClick = useCallback(() => { @@ -85,24 +102,27 @@ export function InputArea({ }, []); // ファイルを添付する - const addFile = useCallback((file: FileAttachment) => { - setAttachedFiles((prev) => { - if (prev.some((f) => f.filePath === file.filePath)) return prev; - return [...prev, file]; - }); - setShowFilePicker(false); - // # トリガーの場合はテキストから #query を消す - if (hashTrigger.active) { - setText((prev) => { - const before = prev.slice(0, hashTrigger.startIndex); - const after = prev.slice(hashTrigger.startIndex + 1 + hashQuery.length); - return before + after; + const addFile = useCallback( + (file: FileAttachment) => { + setAttachedFiles((prev) => { + if (prev.some((f) => f.filePath === file.filePath)) return prev; + return [...prev, file]; }); - setHashTrigger({ active: false, startIndex: -1 }); - setHashQuery(""); - } - textareaRef.current?.focus(); - }, [hashTrigger, hashQuery]); + setShowFilePicker(false); + // # トリガーの場合はテキストから #query を消す + if (hashTrigger.active) { + setText((prev) => { + const before = prev.slice(0, hashTrigger.startIndex); + const after = prev.slice(hashTrigger.startIndex + 1 + hashQuery.length); + return before + after; + }); + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + } + textareaRef.current?.focus(); + }, + [hashTrigger, hashQuery], + ); // ファイルを添付解除する const removeFile = useCallback((filePath: string) => { @@ -125,8 +145,12 @@ export function InputArea({ useEffect(() => { if (!hashTrigger.active) return; const handler = (e: MouseEvent) => { - if (hashPopupRef.current && !hashPopupRef.current.contains(e.target as Node) && - textareaRef.current && !textareaRef.current.contains(e.target as Node)) { + if ( + hashPopupRef.current && + !hashPopupRef.current.contains(e.target as Node) && + textareaRef.current && + !textareaRef.current.contains(e.target as Node) + ) { setHashTrigger({ active: false, startIndex: -1 }); setHashQuery(""); } @@ -176,36 +200,42 @@ export function InputArea({ [handleSend, isBusy, hashTrigger.active], ); - const handleTextChange = useCallback((e: React.ChangeEvent) => { - const newText = e.target.value; - setText(newText); + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + const newText = e.target.value; + setText(newText); - // # トリガー検出 - const cursorPos = e.target.selectionStart; - if (newText.length > text.length) { - // 文字追加時 - const addedChar = newText[cursorPos - 1]; - if (addedChar === "#" && (cursorPos === 1 || newText[cursorPos - 2] === " " || newText[cursorPos - 2] === "\n")) { - // # の前が空白・改行・先頭の場合にトリガーを開始 - setHashTrigger({ active: true, startIndex: cursorPos - 1 }); - setHashQuery(""); - postMessage({ type: "getOpenEditors" }); - return; + // # トリガー検出 + const cursorPos = e.target.selectionStart; + if (newText.length > text.length) { + // 文字追加時 + const addedChar = newText[cursorPos - 1]; + if ( + addedChar === "#" && + (cursorPos === 1 || newText[cursorPos - 2] === " " || newText[cursorPos - 2] === "\n") + ) { + // # の前が空白・改行・先頭の場合にトリガーを開始 + setHashTrigger({ active: true, startIndex: cursorPos - 1 }); + setHashQuery(""); + postMessage({ type: "getOpenEditors" }); + return; + } } - } - // # トリガーがアクティブなら、クエリを更新する - if (hashTrigger.active) { - const queryPart = newText.slice(hashTrigger.startIndex + 1, cursorPos); - // スペースまたは改行が含まれたらトリガー終了 - if (/[\s]/.test(queryPart) || cursorPos <= hashTrigger.startIndex) { - setHashTrigger({ active: false, startIndex: -1 }); - setHashQuery(""); - } else { - setHashQuery(queryPart); + // # トリガーがアクティブなら、クエリを更新する + if (hashTrigger.active) { + const queryPart = newText.slice(hashTrigger.startIndex + 1, cursorPos); + // スペースまたは改行が含まれたらトリガー終了 + if (/[\s]/.test(queryPart) || cursorPos <= hashTrigger.startIndex) { + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + } else { + setHashQuery(queryPart); + } } - } - }, [text, hashTrigger]); + }, + [text, hashTrigger], + ); const handleInput = useCallback(() => { const el = textareaRef.current; @@ -217,21 +247,29 @@ export function InputArea({ // ファイルピッカーに表示するリスト: 検索クエリがあればworkspaceFiles、なければopenEditors + workspaceFiles const pickerFiles = filePickerQuery ? workspaceFiles.filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) - : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))] - .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)); + : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))].filter( + (f) => !attachedFiles.some((a) => a.filePath === f.filePath), + ); // # トリガーのファイル候補 const hashFiles = hashQuery - ? workspaceFiles.filter((f) => - f.fileName.toLowerCase().includes(hashQuery.toLowerCase()) || - f.filePath.toLowerCase().includes(hashQuery.toLowerCase()), - ).filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)).slice(0, 10) + ? workspaceFiles + .filter( + (f) => + f.fileName.toLowerCase().includes(hashQuery.toLowerCase()) || + f.filePath.toLowerCase().includes(hashQuery.toLowerCase()), + ) + .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + .slice(0, 10) : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))] - .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)).slice(0, 10); + .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + .slice(0, 10); // 現在アクティブなエディタファイル (リストの先頭) const activeEditorFile = openEditors.length > 0 ? openEditors[0] : null; - const isActiveAttached = activeEditorFile ? attachedFiles.some((f) => f.filePath === activeEditorFile.filePath) : false; + const isActiveAttached = activeEditorFile + ? attachedFiles.some((f) => f.filePath === activeEditorFile.filePath) + : false; return (
@@ -241,8 +279,13 @@ export function InputArea({
{/* クリップボタン */}
- @@ -292,11 +331,12 @@ export function InputArea({ {/* 現在開いているファイルの quick-add ボタン */} {activeEditorFile && !isActiveAttached && (
{isBusy ? ( - - ) : ( - - )} + + ) : ( + + )}
diff --git a/webview/components/MessageItem.tsx b/webview/components/MessageItem.tsx index a4fda0f..92fe859 100644 --- a/webview/components/MessageItem.tsx +++ b/webview/components/MessageItem.tsx @@ -1,10 +1,10 @@ -import { useState, useRef, useEffect, useCallback } from "react"; -import type { Permission, TextPart, ReasoningPart as ReasoningPartType } from "@opencode-ai/sdk"; +import type { Permission, ReasoningPart as ReasoningPartType, TextPart } from "@opencode-ai/sdk"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { MessageWithParts } from "../App"; import { useLocale } from "../locales"; +import { PermissionView } from "./PermissionView"; import { TextPartView } from "./TextPartView"; import { ToolPartView } from "./ToolPartView"; -import { PermissionView } from "./PermissionView"; type Props = { message: MessageWithParts; @@ -22,9 +22,7 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe const editRef = useRef(null); // このメッセージに紐づくパーミッションリクエストを取得する - const messagePermissions = Array.from(permissions.values()).filter( - (p) => p.messageID === info.id, - ); + const messagePermissions = Array.from(permissions.values()).filter((p) => p.messageID === info.id); // ユーザーメッセージはテキストパートのみ抽出 // synthetic かつテキストが空でないものは SDK がファイルコンテキスト用に生成したもの @@ -32,20 +30,20 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe const textParts = isUser ? parts.filter((p) => p.type === "text") : []; const nonSyntheticTexts = textParts.filter((p) => !(p as TextPart).synthetic); const displayTextParts = nonSyntheticTexts.length > 0 ? nonSyntheticTexts : textParts; - const userText = isUser - ? displayTextParts.map((p) => (p as { text: string }).text).join("") - : null; + const userText = isUser ? displayTextParts.map((p) => (p as { text: string }).text).join("") : null; // ユーザーメッセージに添付されたファイルパートを取得する const userFiles = isUser - ? parts.filter((p) => p.type === "file").map((p) => { - const fp = p as { filename?: string; url?: string }; - const name = fp.filename ?? fp.url ?? "file"; - // file:// プレフィックスを除去し、パスのファイル名だけ表示する - const cleaned = name.replace(/^file:\/\//, ""); - const basename = cleaned.split("/").pop() ?? cleaned; - return basename; - }) + ? parts + .filter((p) => p.type === "file") + .map((p) => { + const fp = p as { filename?: string; url?: string }; + const name = fp.filename ?? fp.url ?? "file"; + // file:// プレフィックスを除去し、パスのファイル名だけ表示する + const cleaned = name.replace(/^file:\/\//, ""); + const basename = cleaned.split("/").pop() ?? cleaned; + return basename; + }) : []; const handleStartEdit = useCallback(() => { @@ -70,15 +68,18 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe onEditAndResend(info.id, trimmed); }, [editText, info.id, onEditAndResend]); - const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleEditSubmit(); - } - if (e.key === "Escape") { - setEditing(false); - } - }, [handleEditSubmit]); + const handleEditKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleEditSubmit(); + } + if (e.key === "Escape") { + setEditing(false); + } + }, + [handleEditSubmit], + ); return (
@@ -99,15 +100,24 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe rows={1} />
- - + +
) : (
{userText}
- +
@@ -116,7 +126,9 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe {userFiles.length > 0 && (
{userFiles.map((name, i) => ( - {name} + + {name} + ))}
)} @@ -155,12 +167,19 @@ function ReasoningPartView({ part }: { part: ReasoningPartType }) {
setExpanded((s) => !s)} title={t["message.toggleThought"]}> {isComplete ? ( - + ) : ( - + @@ -168,16 +187,12 @@ function ReasoningPartView({ part }: { part: ReasoningPartType }) { {isComplete ? t["message.thought"] : t["message.thinking"]} - +
- {expanded && ( -
- {part.text} -
- )} + {expanded &&
{part.text}
}
); } diff --git a/webview/components/MessagesArea.tsx b/webview/components/MessagesArea.tsx index 926ab7b..40d9260 100644 --- a/webview/components/MessagesArea.tsx +++ b/webview/components/MessagesArea.tsx @@ -1,5 +1,5 @@ -import { useRef, useEffect, useCallback } from "react"; import type { Permission } from "@opencode-ai/sdk"; +import { useEffect, useRef } from "react"; import type { MessageWithParts } from "../App"; import { useLocale } from "../locales"; import { MessageItem } from "./MessageItem"; @@ -14,13 +14,20 @@ type Props = { onRevertToCheckpoint: (assistantMessageId: string, userText: string | null) => void; }; -export function MessagesArea({ messages, sessionBusy, activeSessionId, permissions, onEditAndResend, onRevertToCheckpoint }: Props) { +export function MessagesArea({ + messages, + sessionBusy, + activeSessionId, + permissions, + onEditAndResend, + onRevertToCheckpoint, +}: Props) { const t = useLocale(); const bottomRef = useRef(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, sessionBusy]); + }, []); return (
@@ -46,7 +53,8 @@ export function MessagesArea({ messages, sessionBusy, activeSessionId, permissio // 次のユーザーメッセージのテキストを取得して入力欄に戻す const userMsg = nextMsg; const textParts = userMsg.parts.filter((p) => p.type === "text" && !(p as any).synthetic); - const fallbackParts = textParts.length > 0 ? textParts : userMsg.parts.filter((p) => p.type === "text"); + const fallbackParts = + textParts.length > 0 ? textParts : userMsg.parts.filter((p) => p.type === "text"); const userText = fallbackParts.map((p) => (p as any).text).join("") || null; // revert API は指定 ID 以降を削除するので、user メッセージの ID を渡す // こうすることで assistant メッセージまでは残る @@ -55,8 +63,8 @@ export function MessagesArea({ messages, sessionBusy, activeSessionId, permissio title={t["checkpoint.revertTitle"]} >
-
- {!isCollapsed && provider.models.map((model) => { - const isSelected = - selectedModel?.providerID === provider.id && - selectedModel?.modelID === model.id; - const disabled = !provider.connected; - const badge = statusBadge(model.status); - return ( -
{ - if (disabled) return; - onSelect({ providerID: provider.id, modelID: model.id }); - setOpen(false); - }} - > - - {isSelected ? "✓" : ""} - - - {model.name || model.id} - {badge && {badge}} - - {model.limit && ( - - - {formatContextK(model.limit.context)} - + {!isCollapsed && + provider.models.map((model) => { + const isSelected = + selectedModel?.providerID === provider.id && selectedModel?.modelID === model.id; + const disabled = !provider.connected; + const badge = statusBadge(model.status); + return ( +
{ + if (disabled) return; + onSelect({ providerID: provider.id, modelID: model.id }); + setOpen(false); + }} + > + {isSelected ? "✓" : ""} + + {model.name || model.id} + {badge && {badge}} - )} -
- ); - })} + {model.limit && ( + + {formatContextK(model.limit.context)} + + )} +
+ ); + })}
); })} @@ -175,11 +169,12 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe {hasDisconnected && (
- -
diff --git a/webview/components/SessionList.tsx b/webview/components/SessionList.tsx index 1166cd1..9979594 100644 --- a/webview/components/SessionList.tsx +++ b/webview/components/SessionList.tsx @@ -22,21 +22,12 @@ function formatRelativeTime(timestamp: number): string { return `${days}d`; } -export function SessionList({ - sessions, - activeSessionId, - onSelect, - onDelete, - onClose, -}: Props) { +export function SessionList({ sessions, activeSessionId, onSelect, onDelete, onClose }: Props) { const t = useLocale(); return ( <> {/* Backdrop to close session list on outside click */} -
+
{sessions.length === 0 ? (
@@ -54,15 +45,15 @@ export function SessionList({ title={session.title || t["session.select"]} >
- - {session.title || t["session.untitled"]} - + {session.title || t["session.untitled"]} {formatRelativeTime(session.time.updated)} {hasSummary && ( - + {summary.files} +{summary.additions} @@ -72,6 +63,7 @@ export function SessionList({
diff --git a/webview/components/TextPartView.tsx b/webview/components/TextPartView.tsx index ab38f5c..a114831 100644 --- a/webview/components/TextPartView.tsx +++ b/webview/components/TextPartView.tsx @@ -1,6 +1,6 @@ -import { useMemo } from "react"; -import { marked } from "marked"; import type { TextPart } from "@opencode-ai/sdk"; +import { marked } from "marked"; +import { useMemo } from "react"; // marked のデフォルト設定: XSS 対策のため HTML タグをエスケープする marked.setOptions({ @@ -16,9 +16,6 @@ export function TextPartView({ part }: Props) { return marked.parse(part.text, { async: false }) as string; }, [part.text]); - return ( - - ); + // biome-ignore lint/security/noDangerouslySetInnerHtml: marked v17 はデフォルトで HTML タグをエスケープするため、sanitize 済み HTML の描画に使用する + return ; } diff --git a/webview/components/TodoHeader.tsx b/webview/components/TodoHeader.tsx index 6f93165..535534f 100644 --- a/webview/components/TodoHeader.tsx +++ b/webview/components/TodoHeader.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import type { TodoItem } from "./ToolPartView"; import { useLocale } from "../locales"; +import type { TodoItem } from "./ToolPartView"; type Props = { todos: TodoItem[]; @@ -16,13 +16,20 @@ export function TodoHeader({ todos }: Props) { return (
setExpanded((s) => !s)} title={t["todo.toggleList"]}> - + - - {t["todo.label"]} + {t["todo.label"]} + + {completed}/{total} - {completed}/{total} - + @@ -44,9 +51,7 @@ export function TodoHeader({ todos }: Props) {
  • {isDone ? "✓" : "○"} {todo.content} - {todo.priority && ( - {todo.priority} - )} + {todo.priority && {todo.priority}}
  • ); })} diff --git a/webview/components/ToolConfigPanel.tsx b/webview/components/ToolConfigPanel.tsx index cfbb9bb..c6e132d 100644 --- a/webview/components/ToolConfigPanel.tsx +++ b/webview/components/ToolConfigPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; -import { useLocale } from "../locales"; import type { LocaleSetting } from "../locales"; +import { useLocale } from "../locales"; type Props = { paths: { home: string; config: string; state: string; directory: string } | null; @@ -28,8 +28,8 @@ export function ToolConfigPanel({ paths, onOpenConfigFile, onClose, localeSettin
    {t["config.title"]} - {activeSession?.title || t["header.title.fallback"]}
    diff --git a/webview/components/InputArea.tsx b/webview/components/InputArea.tsx index 2c6c952..d8019e7 100644 --- a/webview/components/InputArea.tsx +++ b/webview/components/InputArea.tsx @@ -4,6 +4,16 @@ import type { LocaleSetting } from "../locales"; import { useLocale } from "../locales"; import type { AllProvidersData, FileAttachment } from "../vscode-api"; import { postMessage } from "../vscode-api"; +import { + ChevronRightIcon, + ClipIcon, + CloseIcon, + GearIcon, + PlusIcon, + SendIcon, + StopIcon, + TerminalIcon, +} from "./atoms/icons"; import { ContextIndicator } from "./ContextIndicator"; import { ModelSelector } from "./ModelSelector"; import { ToolConfigPanel } from "./ToolConfigPanel"; @@ -285,9 +295,7 @@ export function InputArea({ onClick={handleClipClick} title={t["input.addContext"]} > - + {showFilePicker && (
    @@ -322,9 +330,7 @@ export function InputArea({ onClick={() => removeFile(file.filePath)} title={t["input.remove"]} > - +
    ))} @@ -336,9 +342,7 @@ export function InputArea({ onClick={() => addFile(activeEditorFile)} title={t["input.addFile"](activeEditorFile.filePath)} > - + {activeEditorFile.fileName} )} @@ -403,9 +407,7 @@ export function InputArea({ onClick={onOpenTerminal} title={t["input.openTerminal"]} > - + {showToolConfig && ( @@ -434,9 +432,7 @@ export function InputArea({
    {isBusy ? ( ) : ( )}
    diff --git a/webview/components/MessageItem.tsx b/webview/components/MessageItem.tsx index 92fe859..df54ba6 100644 --- a/webview/components/MessageItem.tsx +++ b/webview/components/MessageItem.tsx @@ -2,6 +2,7 @@ import type { Permission, ReasoningPart as ReasoningPartType, TextPart } from "@ import { useCallback, useEffect, useRef, useState } from "react"; import type { MessageWithParts } from "../App"; import { useLocale } from "../locales"; +import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "./atoms/icons"; import { PermissionView } from "./PermissionView"; import { TextPartView } from "./TextPartView"; import { ToolPartView } from "./ToolPartView"; @@ -117,9 +118,7 @@ export function MessageItem({ message, activeSessionId, permissions, onEditAndRe
    {userText}
    - +
    )} @@ -167,29 +166,14 @@ function ReasoningPartView({ part }: { part: ReasoningPartType }) {
    setExpanded((s) => !s)} title={t["message.toggleThought"]}> {isComplete ? ( - + ) : ( - + )} {isComplete ? t["message.thought"] : t["message.thinking"]} - +
    {expanded &&
    {part.text}
    } diff --git a/webview/components/MessagesArea.tsx b/webview/components/MessagesArea.tsx index 40d9260..c9bdad0 100644 --- a/webview/components/MessagesArea.tsx +++ b/webview/components/MessagesArea.tsx @@ -2,6 +2,7 @@ import type { Permission } from "@opencode-ai/sdk"; import { useEffect, useRef } from "react"; import type { MessageWithParts } from "../App"; import { useLocale } from "../locales"; +import { RevertIcon } from "./atoms/icons"; import { MessageItem } from "./MessageItem"; import { StreamingIndicator } from "./StreamingIndicator"; @@ -64,9 +65,7 @@ export function MessagesArea({ >
    diff --git a/webview/components/ModelSelector.tsx b/webview/components/ModelSelector.tsx index 0893159..f3f05ab 100644 --- a/webview/components/ModelSelector.tsx +++ b/webview/components/ModelSelector.tsx @@ -2,6 +2,7 @@ import type { Provider } from "@opencode-ai/sdk"; import { useEffect, useMemo, useRef, useState } from "react"; import { useLocale } from "../locales"; import type { AllProvidersData, ModelInfo, ProviderInfo } from "../vscode-api"; +import { ChevronRightIcon, EyeIcon, EyeOffIcon } from "./atoms/icons"; type Props = { providers: Provider[]; @@ -106,9 +107,7 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe > {selectedModelName} - + {open && ( @@ -124,9 +123,7 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe onClick={() => toggleProvider(provider.id)} > - + {provider.name} {!provider.connected && ( @@ -174,13 +171,7 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe onClick={() => setShowAll((s) => !s)} title={showAll ? t["model.hideDisconnected"] : t["model.showAll"]} > - + {showAll ? : } {showAll ? t["model.connectedOnly"] : t["model.showAll"]}
    diff --git a/webview/components/SessionList.tsx b/webview/components/SessionList.tsx index 9979594..8b86c69 100644 --- a/webview/components/SessionList.tsx +++ b/webview/components/SessionList.tsx @@ -1,5 +1,6 @@ import type { Session } from "@opencode-ai/sdk"; import { useLocale } from "../locales"; +import { DeleteIcon, FileIcon } from "./atoms/icons"; type Props = { sessions: Session[]; @@ -51,9 +52,7 @@ export function SessionList({ sessions, activeSessionId, onSelect, onDelete, onC {hasSummary && ( - + {summary.files} +{summary.additions} @@ -71,9 +70,7 @@ export function SessionList({ sessions, activeSessionId, onSelect, onDelete, onC }} title={t["session.delete"]} > - +
    ); diff --git a/webview/components/TodoHeader.tsx b/webview/components/TodoHeader.tsx index 535534f..9551beb 100644 --- a/webview/components/TodoHeader.tsx +++ b/webview/components/TodoHeader.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useLocale } from "../locales"; import type { TodoItem } from "./ToolPartView"; +import { CheckboxIcon, ChevronRightIcon } from "./atoms/icons"; type Props = { todos: TodoItem[]; @@ -16,16 +17,7 @@ export function TodoHeader({ todos }: Props) { return (
    setExpanded((s) => !s)} title={t["todo.toggleList"]}> - + {t["todo.label"]} {completed}/{total} @@ -37,9 +29,7 @@ export function TodoHeader({ todos }: Props) { /> - +
    {expanded && ( diff --git a/webview/components/ToolConfigPanel.tsx b/webview/components/ToolConfigPanel.tsx index c6e132d..8ba51f4 100644 --- a/webview/components/ToolConfigPanel.tsx +++ b/webview/components/ToolConfigPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import type { LocaleSetting } from "../locales"; import { useLocale } from "../locales"; +import { CloseIcon, FileIcon } from "./atoms/icons"; type Props = { paths: { home: string; config: string; state: string; directory: string } | null; @@ -29,9 +30,7 @@ export function ToolConfigPanel({ paths, onOpenConfigFile, onClose, localeSettin
    {t["config.title"]}
    @@ -67,9 +66,7 @@ export function ToolConfigPanel({ paths, onOpenConfigFile, onClose, localeSettin className="tool-config-link" onClick={() => onOpenConfigFile(`${paths.directory}/opencode.json`)} > - + {t["config.projectConfig"]}
    diff --git a/webview/components/ToolPartView.tsx b/webview/components/ToolPartView.tsx index c4c6844..302b12c 100644 --- a/webview/components/ToolPartView.tsx +++ b/webview/components/ToolPartView.tsx @@ -1,6 +1,17 @@ import type { ToolPart } from "@opencode-ai/sdk"; import { useMemo, useState } from "react"; import { useLocale } from "../locales"; +import { + ChevronRightIcon, + EditActionIcon, + ErrorCircleIcon, + ReadActionIcon, + RunActionIcon, + SearchActionIcon, + SpinnerIcon, + ToolIcon, + WriteActionIcon, +} from "./atoms/icons"; type Props = { part: ToolPart; @@ -246,41 +257,17 @@ function FileCreateView({ content }: { content: string }) { function ActionIcon({ category }: { category: ToolCategory }) { switch (category) { case "read": - return ( - - ); + return ; case "edit": - return ( - - ); + return ; case "write": - return ( - - ); + return ; case "run": - return ( - - ); + return ; case "search": - return ( - - ); + return ; default: - return ( - - ); + return ; } } @@ -334,22 +321,9 @@ export function ToolPartView({ part }: Props) {
    setExpanded((s) => !s)} title={t["tool.toggleDetails"]}> {isActive ? ( - + ) : isError ? ( - + ) : ( )} @@ -361,9 +335,7 @@ export function ToolPartView({ part }: Props) { )} - +
    {expanded && ( diff --git a/webview/components/atoms/icons.tsx b/webview/components/atoms/icons.tsx new file mode 100644 index 0000000..324c773 --- /dev/null +++ b/webview/components/atoms/icons.tsx @@ -0,0 +1,264 @@ +import type { SVGProps } from "react"; + +// Common props for all icon components. +// Consumers can override width, height, className, and any standard SVG attribute. +export type IconProps = SVGProps; + +// ─── Navigation / Header ───────────────────────────────────────────── + +/** Codicon: list-unordered */ +export function ListIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: add (16px plus) */ +export function AddIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Small plus icon (12px) used for quick-add buttons */ +export function PlusIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +/** Chevron right arrow — used for expand/collapse toggles */ +export function ChevronRightIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +// ─── File / Document ────────────────────────────────────────────────── + +/** Codicon: file */ +export function FileIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: paperclip / attach */ +export function ClipIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +// ─── Action ─────────────────────────────────────────────────────────── + +/** Codicon: close (×) */ +export function CloseIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: trash / delete */ +export function DeleteIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: edit / pencil */ +export function EditIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: send arrow */ +export function SendIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: stop (square) */ +export function StopIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Revert / retry arrows — used for checkpoint revert */ +export function RevertIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +// ─── Input Area Actions ─────────────────────────────────────────────── + +/** Codicon: terminal */ +export function TerminalIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: gear / settings */ +export function GearIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +// ─── Tool / Status ──────────────────────────────────────────────────── + +/** Loading spinner — animated rotating arc */ +export function SpinnerIcon({ width = 16, height = 16, className, ...props }: IconProps) { + return ( + + ); +} + +/** Error circle with exclamation mark */ +export function ErrorCircleIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Info circle — used for completed reasoning/thought indicator */ +export function InfoCircleIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Checkbox with checkmark */ +export function CheckboxIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +// ─── Tool Action Category Icons ─────────────────────────────────────── + +/** Read action icon — open file/document */ +export function ReadActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Edit action icon — pencil (same path as EditIcon) */ +export function EditActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Write/create action icon — file upload */ +export function WriteActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Run action icon — play button in terminal */ +export function RunActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Search action icon — magnifying glass */ +export function SearchActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Generic tool/file icon — default for uncategorized tools */ +export function ToolIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +// ─── Visibility ─────────────────────────────────────────────────────── + +/** Eye icon — visible / show */ +export function EyeIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Eye-off icon — hidden / hide */ +export function EyeOffIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} From e5a344143def86788fdbc5d4134845a390483250 Mon Sep 17 00:00:00 2001 From: ktmage Date: Thu, 26 Feb 2026 02:44:52 +0900 Subject: [PATCH 08/84] refactor: reorganize imports and improve test coverage for utility functions --- vitest.config.ts | 2 +- webview/App.tsx | 4 +- webview/__tests__/utils/diff.test.ts | 91 ++++++++++++ webview/__tests__/utils/todo.test.ts | 72 ++++++++++ .../__tests__/utils/tool-categories.test.ts | 59 ++++++++ webview/components/TodoHeader.tsx | 2 +- webview/components/ToolPartView.tsx | 136 +----------------- webview/utils/diff.ts | 62 ++++++++ webview/utils/todo.ts | 13 ++ webview/utils/tool-categories.ts | 47 ++++++ 10 files changed, 352 insertions(+), 136 deletions(-) create mode 100644 webview/__tests__/utils/diff.test.ts create mode 100644 webview/__tests__/utils/todo.test.ts create mode 100644 webview/__tests__/utils/tool-categories.test.ts create mode 100644 webview/utils/diff.ts create mode 100644 webview/utils/todo.ts create mode 100644 webview/utils/tool-categories.ts diff --git a/vitest.config.ts b/vitest.config.ts index f19162e..ba4566f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ environment: "jsdom", root: "webview", setupFiles: ["./__tests__/setup.ts"], - include: ["./__tests__/**/*.test.tsx"], + include: ["./__tests__/**/*.test.{ts,tsx}"], globals: true, }, }); diff --git a/webview/App.tsx b/webview/App.tsx index f7eed0f..71b4618 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -6,8 +6,8 @@ import { InputArea } from "./components/InputArea"; import { MessagesArea } from "./components/MessagesArea"; import { SessionList } from "./components/SessionList"; import { TodoHeader } from "./components/TodoHeader"; -import type { TodoItem } from "./components/ToolPartView"; -import { parseTodos } from "./components/ToolPartView"; +import type { TodoItem } from "./utils/todo"; +import { parseTodos } from "./utils/todo"; import type { LocaleSetting } from "./locales"; import { getStrings, LocaleProvider, resolveLocale } from "./locales"; import type { AllProvidersData, ExtToWebviewMessage, FileAttachment } from "./vscode-api"; diff --git a/webview/__tests__/utils/diff.test.ts b/webview/__tests__/utils/diff.test.ts new file mode 100644 index 0000000..b7ff53a --- /dev/null +++ b/webview/__tests__/utils/diff.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { type DiffLine, computeLineDiff } from "../../utils/diff"; + +describe("computeLineDiff", () => { + // when both strings are identical + context("同一文字列の場合", () => { + it("変更行を含まないこと", () => { + const result = computeLineDiff("hello\nworld", "hello\nworld"); + expect(result.every((l) => l.type === "context")).toBe(true); + }); + }); + + // when a line is added + context("行が追加された場合", () => { + it("add タイプの行を含むこと", () => { + const result = computeLineDiff("a", "a\nb"); + expect(result.some((l) => l.type === "add" && l.text === "b")).toBe(true); + }); + }); + + // when a line is removed + context("行が削除された場合", () => { + it("remove タイプの行を含むこと", () => { + const result = computeLineDiff("a\nb", "a"); + expect(result.some((l) => l.type === "remove" && l.text === "b")).toBe(true); + }); + }); + + // when a line is changed + context("行が変更された場合", () => { + it("remove タイプの行を含むこと", () => { + const result = computeLineDiff("hello", "world"); + expect(result.some((l) => l.type === "remove")).toBe(true); + }); + + it("add タイプの行を含むこと", () => { + const result = computeLineDiff("hello", "world"); + expect(result.some((l) => l.type === "add")).toBe(true); + }); + }); + + // when context lines are near changes + context("変更行の近くにコンテキスト行がある場合", () => { + it("変更行の前後2行以内のコンテキスト行を含むこと", () => { + const old = "1\n2\n3\n4\n5"; + const new_ = "1\n2\nX\n4\n5"; + const result = computeLineDiff(old, new_); + const contextLines = result.filter((l) => l.type === "context"); + expect(contextLines.length).toBeGreaterThan(0); + }); + }); + + // when context lines are far from changes + context("変更行から離れたコンテキスト行がある場合", () => { + it("遠いコンテキスト行を除外すること", () => { + const lines = Array.from({ length: 20 }, (_, i) => String(i)); + const oldStr = lines.join("\n"); + const newLines = [...lines]; + newLines[0] = "changed"; + const newStr = newLines.join("\n"); + const result = computeLineDiff(oldStr, newStr); + // line "10" is far from the change at index 0 + expect(result.some((l) => l.type === "context" && l.text === "10")).toBe(false); + }); + }); + + // when input exceeds 500 lines + context("入力が500行を超える場合", () => { + it("全行を remove/add としてフォールバックすること", () => { + const oldStr = Array.from({ length: 501 }, (_, i) => `old${i}`).join("\n"); + const newStr = Array.from({ length: 501 }, (_, i) => `new${i}`).join("\n"); + const result = computeLineDiff(oldStr, newStr); + const removes = result.filter((l) => l.type === "remove"); + const adds = result.filter((l) => l.type === "add"); + expect(removes.length + adds.length).toBe(1002); + }); + }); + + // when one string is empty + context("片方が空文字列の場合", () => { + it("空→テキストの場合、新しい行が add として含まれること", () => { + const result = computeLineDiff("", "a\nb"); + expect(result.some((l) => l.type === "add" && l.text === "a")).toBe(true); + }); + + it("テキスト→空の場合、元の行が remove として含まれること", () => { + const result = computeLineDiff("a\nb", ""); + expect(result.some((l) => l.type === "remove" && l.text === "a")).toBe(true); + }); + }); +}); diff --git a/webview/__tests__/utils/todo.test.ts b/webview/__tests__/utils/todo.test.ts new file mode 100644 index 0000000..962303d --- /dev/null +++ b/webview/__tests__/utils/todo.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { type TodoItem, parseTodos } from "../../utils/todo"; + +describe("parseTodos", () => { + // when given a valid JSON string + context("有効な JSON 文字列の場合", () => { + it("TodoItem 配列を返すこと", () => { + const json = JSON.stringify([{ content: "task1" }, { content: "task2", status: "done" }]); + const result = parseTodos(json); + expect(result).toEqual([{ content: "task1" }, { content: "task2", status: "done" }]); + }); + }); + + // when given an object array directly + context("オブジェクト配列を直接渡した場合", () => { + it("TodoItem 配列を返すこと", () => { + const arr = [{ content: "task1", priority: "high" }]; + expect(parseTodos(arr)).toEqual(arr); + }); + }); + + // when given a wrapper object with "todos" key + context("todos キーを持つラッパーオブジェクトの場合", () => { + it("todos 配列を返すこと", () => { + const data = { todos: [{ content: "a" }] }; + expect(parseTodos(data)).toEqual([{ content: "a" }]); + }); + }); + + // when given a wrapper object with "items" key + context("items キーを持つラッパーオブジェクトの場合", () => { + it("items 配列を返すこと", () => { + const data = { items: [{ content: "b" }] }; + expect(parseTodos(data)).toEqual([{ content: "b" }]); + }); + }); + + // when given an empty array + context("空配列の場合", () => { + it("null を返すこと", () => { + expect(parseTodos([])).toBeNull(); + }); + }); + + // when given items without "content" property + context("content プロパティを持たないアイテムの場合", () => { + it("null を返すこと", () => { + expect(parseTodos([{ title: "no content" }])).toBeNull(); + }); + }); + + // when given invalid JSON string + context("不正な JSON 文字列の場合", () => { + it("null を返すこと", () => { + expect(parseTodos("{invalid")).toBeNull(); + }); + }); + + // when given null + context("null の場合", () => { + it("null を返すこと", () => { + expect(parseTodos(null)).toBeNull(); + }); + }); + + // when given a number + context("数値の場合", () => { + it("null を返すこと", () => { + expect(parseTodos(42)).toBeNull(); + }); + }); +}); diff --git a/webview/__tests__/utils/tool-categories.test.ts b/webview/__tests__/utils/tool-categories.test.ts new file mode 100644 index 0000000..3cc4679 --- /dev/null +++ b/webview/__tests__/utils/tool-categories.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { CATEGORY_LABEL_KEYS, TOOL_CATEGORIES, type ToolCategory, getCategory } from "../../utils/tool-categories"; + +describe("TOOL_CATEGORIES", () => { + it("全エントリが有効な ToolCategory 値を持つこと", () => { + const validCategories: ToolCategory[] = ["read", "edit", "write", "run", "search", "other"]; + for (const value of Object.values(TOOL_CATEGORIES)) { + expect(validCategories).toContain(value); + } + }); +}); + +describe("CATEGORY_LABEL_KEYS", () => { + it("全 ToolCategory に対応するラベルキーを持つこと", () => { + const categories: ToolCategory[] = ["read", "edit", "write", "run", "search", "other"]; + for (const cat of categories) { + expect(CATEGORY_LABEL_KEYS[cat]).toBeDefined(); + } + }); +}); + +describe("getCategory", () => { + // when tool name exactly matches + context("ツール名が完全一致する場合", () => { + it("対応するカテゴリを返すこと", () => { + expect(getCategory("read")).toBe("read"); + }); + + it("bash は run を返すこと", () => { + expect(getCategory("bash")).toBe("run"); + }); + + it("grep は search を返すこと", () => { + expect(getCategory("grep")).toBe("search"); + }); + + it("write は write を返すこと", () => { + expect(getCategory("write")).toBe("write"); + }); + }); + + // when tool name has a prefix (MCP tools) + context("プレフィクス付きのツール名の場合", () => { + it("アンダースコア区切りの末尾でマッチすること", () => { + expect(getCategory("mcp_read")).toBe("read"); + }); + + it("スラッシュ区切りの末尾でマッチすること", () => { + expect(getCategory("server/bash")).toBe("run"); + }); + }); + + // when tool name is unknown + context("未知のツール名の場合", () => { + it("other を返すこと", () => { + expect(getCategory("unknown_tool_xyz")).toBe("other"); + }); + }); +}); diff --git a/webview/components/TodoHeader.tsx b/webview/components/TodoHeader.tsx index 9551beb..a677e34 100644 --- a/webview/components/TodoHeader.tsx +++ b/webview/components/TodoHeader.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useLocale } from "../locales"; -import type { TodoItem } from "./ToolPartView"; +import type { TodoItem } from "../utils/todo"; import { CheckboxIcon, ChevronRightIcon } from "./atoms/icons"; type Props = { diff --git a/webview/components/ToolPartView.tsx b/webview/components/ToolPartView.tsx index 302b12c..8c731e8 100644 --- a/webview/components/ToolPartView.tsx +++ b/webview/components/ToolPartView.tsx @@ -1,6 +1,10 @@ import type { ToolPart } from "@opencode-ai/sdk"; import { useMemo, useState } from "react"; import { useLocale } from "../locales"; +import type { TodoItem } from "../utils/todo"; +import { parseTodos } from "../utils/todo"; +import { CATEGORY_LABEL_KEYS, getCategory } from "../utils/tool-categories"; +import { computeLineDiff } from "../utils/diff"; import { ChevronRightIcon, EditActionIcon, @@ -17,56 +21,6 @@ type Props = { part: ToolPart; }; -// --- ツール種別からアクションラベルとアイコンを決定 --- - -type ToolCategory = "read" | "edit" | "write" | "run" | "search" | "other"; - -const TOOL_CATEGORIES: Record = { - read: "read", - edit: "edit", - multiedit: "edit", - write: "write", - apply_patch: "edit", - bash: "run", - glob: "search", - grep: "search", - list: "search", - codesearch: "search", - websearch: "search", - webfetch: "read", - lsp: "read", - todowrite: "write", - todoread: "read", - task: "run", - batch: "run", - question: "other", - skill: "run", - plan_enter: "other", - plan_exit: "other", -}; - -const CATEGORY_LABEL_KEYS: Record< - ToolCategory, - "tool.read" | "tool.edit" | "tool.create" | "tool.run" | "tool.search" | "tool.tool" -> = { - read: "tool.read", - edit: "tool.edit", - write: "tool.create", - run: "tool.run", - search: "tool.search", - other: "tool.tool", -}; - -function getCategory(toolName: string): ToolCategory { - // 完全一致 - if (TOOL_CATEGORIES[toolName]) return TOOL_CATEGORIES[toolName]; - // MCP ツールなど: プレフィクスを除去して再チェック - const parts = toolName.split(/[_/]/); - const last = parts[parts.length - 1]; - if (TOOL_CATEGORIES[last]) return TOOL_CATEGORIES[last]; - return "other"; -} - /** タイトル(ファイルパスなど)をフォーマット */ function formatTitle(part: ToolPart): string | null { const { state, tool } = part; @@ -97,88 +51,6 @@ function isFileCreateInput(input: Record): boolean { return typeof input.content === "string" && getFilePath(input) !== null && typeof input.oldString !== "string"; } -// --- Diff 計算 --- - -type DiffLine = { type: "context" | "add" | "remove"; text: string }; - -function computeLineDiff(oldStr: string, newStr: string): DiffLine[] { - const oldLines = oldStr.split("\n"); - const newLines = newStr.split("\n"); - const result: DiffLine[] = []; - - const n = oldLines.length; - const m = newLines.length; - if (n > 500 || m > 500) { - for (const line of oldLines) result.push({ type: "remove", text: line }); - for (const line of newLines) result.push({ type: "add", text: line }); - return result; - } - - const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); - for (let i = 1; i <= n; i++) { - for (let j = 1; j <= m; j++) { - if (oldLines[i - 1] === newLines[j - 1]) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - - const raw: DiffLine[] = []; - let i = n, - j = m; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { - raw.push({ type: "context", text: oldLines[i - 1] }); - i--; - j--; - } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - raw.push({ type: "add", text: newLines[j - 1] }); - j--; - } else { - raw.push({ type: "remove", text: oldLines[i - 1] }); - i--; - } - } - raw.reverse(); - - const hasChange = (idx: number) => raw[idx]?.type !== "context"; - for (let k = 0; k < raw.length; k++) { - if (raw[k].type !== "context") { - result.push(raw[k]); - } else { - let nearChange = false; - for (let d = -2; d <= 2; d++) { - if (hasChange(k + d)) { - nearChange = true; - break; - } - } - if (nearChange) result.push(raw[k]); - } - } - - return result; -} - -// --- ToDo --- - -export type TodoItem = { content: string; status?: string; priority?: string }; - -/** JSON文字列からToDoアイテムを抽出 */ -export function parseTodos(raw: unknown): TodoItem[] | null { - try { - const data = typeof raw === "string" ? JSON.parse(raw) : raw; - const arr = Array.isArray(data) ? data : (data?.todos ?? data?.items ?? null); - if (!Array.isArray(arr) || arr.length === 0) return null; - if (!arr.every((item: unknown) => typeof item === "object" && item !== null && "content" in item)) return null; - return arr as TodoItem[]; - } catch { - return null; - } -} - function TodoView({ todos }: { todos: TodoItem[] }) { const t = useLocale(); const completed = todos.filter((td) => td.status === "completed" || td.status === "done").length; diff --git a/webview/utils/diff.ts b/webview/utils/diff.ts new file mode 100644 index 0000000..6208c97 --- /dev/null +++ b/webview/utils/diff.ts @@ -0,0 +1,62 @@ +export type DiffLine = { type: "context" | "add" | "remove"; text: string }; + +export function computeLineDiff(oldStr: string, newStr: string): DiffLine[] { + const oldLines = oldStr.split("\n"); + const newLines = newStr.split("\n"); + const result: DiffLine[] = []; + + const n = oldLines.length; + const m = newLines.length; + if (n > 500 || m > 500) { + for (const line of oldLines) result.push({ type: "remove", text: line }); + for (const line of newLines) result.push({ type: "add", text: line }); + return result; + } + + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + const raw: DiffLine[] = []; + let i = n, + j = m; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + raw.push({ type: "context", text: oldLines[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + raw.push({ type: "add", text: newLines[j - 1] }); + j--; + } else { + raw.push({ type: "remove", text: oldLines[i - 1] }); + i--; + } + } + raw.reverse(); + + const hasChange = (idx: number) => raw[idx]?.type !== "context"; + for (let k = 0; k < raw.length; k++) { + if (raw[k].type !== "context") { + result.push(raw[k]); + } else { + let nearChange = false; + for (let d = -2; d <= 2; d++) { + if (hasChange(k + d)) { + nearChange = true; + break; + } + } + if (nearChange) result.push(raw[k]); + } + } + + return result; +} diff --git a/webview/utils/todo.ts b/webview/utils/todo.ts new file mode 100644 index 0000000..7ad4f0a --- /dev/null +++ b/webview/utils/todo.ts @@ -0,0 +1,13 @@ +export type TodoItem = { content: string; status?: string; priority?: string }; + +export function parseTodos(raw: unknown): TodoItem[] | null { + try { + const data = typeof raw === "string" ? JSON.parse(raw) : raw; + const arr = Array.isArray(data) ? data : (data?.todos ?? data?.items ?? null); + if (!Array.isArray(arr) || arr.length === 0) return null; + if (!arr.every((item: unknown) => typeof item === "object" && item !== null && "content" in item)) return null; + return arr as TodoItem[]; + } catch { + return null; + } +} diff --git a/webview/utils/tool-categories.ts b/webview/utils/tool-categories.ts new file mode 100644 index 0000000..6d6eb7d --- /dev/null +++ b/webview/utils/tool-categories.ts @@ -0,0 +1,47 @@ +export type ToolCategory = "read" | "edit" | "write" | "run" | "search" | "other"; + +export const TOOL_CATEGORIES: Record = { + read: "read", + edit: "edit", + multiedit: "edit", + write: "write", + apply_patch: "edit", + bash: "run", + glob: "search", + grep: "search", + list: "search", + codesearch: "search", + websearch: "search", + webfetch: "read", + lsp: "read", + todowrite: "write", + todoread: "read", + task: "run", + batch: "run", + question: "other", + skill: "run", + plan_enter: "other", + plan_exit: "other", +}; + +export const CATEGORY_LABEL_KEYS: Record< + ToolCategory, + "tool.read" | "tool.edit" | "tool.create" | "tool.run" | "tool.search" | "tool.tool" +> = { + read: "tool.read", + edit: "tool.edit", + write: "tool.create", + run: "tool.run", + search: "tool.search", + other: "tool.tool", +}; + +export function getCategory(toolName: string): ToolCategory { + // exact match + if (TOOL_CATEGORIES[toolName]) return TOOL_CATEGORIES[toolName]; + // MCP tools etc: strip prefix and re-check + const parts = toolName.split(/[_/]/); + const last = parts[parts.length - 1]; + if (TOOL_CATEGORIES[last]) return TOOL_CATEGORIES[last]; + return "other"; +} From e312c8740e3f6c859f5ac941cac2906cf8f788ec Mon Sep 17 00:00:00 2001 From: ktmage Date: Thu, 26 Feb 2026 03:40:09 +0900 Subject: [PATCH 09/84] feat: add hooks and tests for locale, messages, permissions, providers, and session management - Implemented `useLocale` hook for managing locale settings and strings. - Created `useMessages` hook for handling chat messages and their parts. - Developed `usePermissions` hook for managing tool permission requests. - Added `useProviders` hook for managing AI model providers and their data. - Introduced `useSession` hook for managing session states and events. - Added comprehensive tests for each hook to ensure functionality and correctness. - Created `AppContext` for centralized state management across the application. --- webview/App.tsx | 459 +++++++----------- webview/__tests__/hooks/useLocale.test.ts | 62 +++ webview/__tests__/hooks/useMessages.test.ts | 180 +++++++ .../__tests__/hooks/usePermissions.test.ts | 73 +++ webview/__tests__/hooks/useProviders.test.ts | 99 ++++ webview/__tests__/hooks/useSession.test.ts | 157 ++++++ webview/contexts/AppContext.tsx | 68 +++ webview/hooks/useLocale.ts | 28 ++ webview/hooks/useMessages.ts | 120 +++++ webview/hooks/usePermissions.ts | 45 ++ webview/hooks/useProviders.ts | 35 ++ webview/hooks/useSession.ts | 67 +++ 12 files changed, 1108 insertions(+), 285 deletions(-) create mode 100644 webview/__tests__/hooks/useLocale.test.ts create mode 100644 webview/__tests__/hooks/useMessages.test.ts create mode 100644 webview/__tests__/hooks/usePermissions.test.ts create mode 100644 webview/__tests__/hooks/useProviders.test.ts create mode 100644 webview/__tests__/hooks/useSession.test.ts create mode 100644 webview/contexts/AppContext.tsx create mode 100644 webview/hooks/useLocale.ts create mode 100644 webview/hooks/useMessages.ts create mode 100644 webview/hooks/usePermissions.ts create mode 100644 webview/hooks/useProviders.ts create mode 100644 webview/hooks/useSession.ts diff --git a/webview/App.tsx b/webview/App.tsx index 71b4618..09d757c 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,36 +1,34 @@ -import type { Event, Message, Part, Permission, Provider, Session } from "@opencode-ai/sdk"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Event } from "@opencode-ai/sdk"; +import { useCallback, useEffect, useState } from "react"; import { ChatHeader } from "./components/ChatHeader"; import { EmptyState } from "./components/EmptyState"; import { InputArea } from "./components/InputArea"; import { MessagesArea } from "./components/MessagesArea"; import { SessionList } from "./components/SessionList"; import { TodoHeader } from "./components/TodoHeader"; -import type { TodoItem } from "./utils/todo"; -import { parseTodos } from "./utils/todo"; -import type { LocaleSetting } from "./locales"; -import { getStrings, LocaleProvider, resolveLocale } from "./locales"; -import type { AllProvidersData, ExtToWebviewMessage, FileAttachment } from "./vscode-api"; -import { getPersistedState, postMessage, setPersistedState } from "./vscode-api"; +import { AppContextProvider, type AppContextValue } from "./contexts/AppContext"; +import { useLocale } from "./hooks/useLocale"; +import { useMessages } from "./hooks/useMessages"; +import { usePermissions } from "./hooks/usePermissions"; +import { useProviders } from "./hooks/useProviders"; +import { useSession } from "./hooks/useSession"; +import { LocaleProvider } from "./locales"; +import type { ExtToWebviewMessage, FileAttachment } from "./vscode-api"; +import { postMessage } from "./vscode-api"; -export type MessageWithParts = { info: Message; parts: Part[] }; +// re-export for consumers that import from App.tsx +export type { MessageWithParts } from "./hooks/useMessages"; export function App() { - const [sessions, setSessions] = useState([]); - const [activeSession, setActiveSession] = useState(null); - const [messages, setMessages] = useState([]); - const [showSessionList, setShowSessionList] = useState(false); - const [sessionBusy, setSessionBusy] = useState(false); - // パーミッションリクエストを messageID でグルーピングして管理する - const [permissions, setPermissions] = useState>(new Map()); - const [providers, setProviders] = useState([]); - const [allProvidersData, setAllProvidersData] = useState(null); - const [selectedModel, setSelectedModel] = useState<{ providerID: string; modelID: string } | null>(null); + const session = useSession(); + const msg = useMessages(); + const prov = useProviders(); + const perm = usePermissions(); + const locale = useLocale(); + + // Extension Host → Webview メッセージでのみ更新される単純なステート const [openEditors, setOpenEditors] = useState([]); const [workspaceFiles, setWorkspaceFiles] = useState([]); - // チェックポイントからの復元時にテキストを入力欄にプリフィルするためのステート - const [prefillText, setPrefillText] = useState(""); - // 設定パネル用のステート const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; @@ -38,151 +36,55 @@ export function App() { directory: string; } | null>(null); - // ロケール管理 - const [localeSetting, setLocaleSetting] = useState( - () => (getPersistedState()?.localeSetting as LocaleSetting) ?? "auto", - ); - const [vscodeLanguage, setVscodeLanguage] = useState("en"); - const resolvedLocale = useMemo(() => resolveLocale(localeSetting, vscodeLanguage), [localeSetting, vscodeLanguage]); - const strings = useMemo(() => getStrings(resolvedLocale), [resolvedLocale]); - - const handleLocaleSettingChange = useCallback((setting: LocaleSetting) => { - setLocaleSetting(setting); - setPersistedState({ ...getPersistedState(), localeSetting: setting }); + const handleOpenConfigFile = useCallback((filePath: string) => { + postMessage({ type: "openConfigFile", filePath }); }, []); - // messages から StepFinishPart のトークン使用量を導出する(圧縮でメッセージが減ると自動的に反映される) - const inputTokens = useMemo(() => { - let total = 0; - for (const m of messages) { - for (const p of m.parts) { - if (p.type === "step-finish" && p.tokens) { - total += p.tokens.input; - } - } - } - return total; - }, [messages]); - - // 選択中のモデルのコンテキストリミットを算出 - const contextLimit = useMemo(() => { - if (!selectedModel) return 0; - const provider = providers.find((p) => p.id === selectedModel.providerID); - if (!provider) return 0; - const model = provider.models[selectedModel.modelID]; - return model?.limit?.context ?? 0; - }, [providers, selectedModel]); - - const handleEvent = useCallback((event: Event) => { - switch (event.type) { - case "message.updated": { - const info = event.properties.info; - setMessages((prev) => { - const idx = prev.findIndex((m) => m.info.id === info.id); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { ...updated[idx], info }; - return updated; - } - return [...prev, { info, parts: [] }]; - }); - break; - } - case "message.part.updated": { - const part = event.properties.part; - setMessages((prev) => { - const idx = prev.findIndex((m) => m.info.id === part.messageID); - if (idx < 0) return prev; - const updated = [...prev]; - const msg = { ...updated[idx] }; - const partIdx = msg.parts.findIndex((p) => p.id === part.id); - if (partIdx >= 0) { - msg.parts = [...msg.parts]; - msg.parts[partIdx] = part; - } else { - msg.parts = [...msg.parts, part]; - } - updated[idx] = msg; - return updated; - }); - break; - } - case "session.status": { - setSessionBusy(event.properties.status.type === "busy"); - break; - } - case "permission.updated": { - const permission = event.properties; - setPermissions((prev) => { - const next = new Map(prev); - next.set(permission.id, permission); - return next; - }); - break; - } - case "permission.replied": { - const permissionID = event.properties.permissionID; - setPermissions((prev) => { - const next = new Map(prev); - next.delete(permissionID); - return next; - }); - break; - } - case "message.removed": { - // 圧縮時にメッセージが削除される → messages から除去すると inputTokens も自動的に再計算される - const { messageID } = event.properties; - setMessages((prev) => prev.filter((m) => m.info.id !== messageID)); - break; - } - case "session.updated": { - const info = event.properties.info; - setSessions((prev) => prev.map((s) => (s.id === info.id ? info : s))); - setActiveSession((prev) => (prev?.id === info.id ? info : prev)); - break; - } - case "session.created": { - setSessions((prev) => [event.properties.info, ...prev]); - break; - } - case "session.deleted": { - const deletedId = event.properties.info.id; - setSessions((prev) => prev.filter((s) => s.id !== deletedId)); - break; - } - } + const handleOpenTerminal = useCallback(() => { + postMessage({ type: "openTerminal" }); }, []); + // SSE event handler — dispatches to domain-specific hooks + const handleEvent = useCallback( + (event: Event) => { + session.handleSessionEvent(event); + msg.handleMessageEvent(event); + perm.handlePermissionEvent(event); + }, + [session.handleSessionEvent, msg.handleMessageEvent, perm.handlePermissionEvent], + ); + + // Extension Host → Webview message listener useEffect(() => { const handler = (e: MessageEvent) => { - const msg = e.data; - switch (msg.type) { + const data = e.data; + switch (data.type) { case "sessions": - setSessions(msg.sessions); + session.setSessions(data.sessions); break; case "messages": - if (msg.sessionId === activeSession?.id) { - setMessages(msg.messages); + if (data.sessionId === session.activeSession?.id) { + msg.setMessages(data.messages); } break; case "activeSession": - setActiveSession(msg.session); - if (msg.session) { - postMessage({ type: "getMessages", sessionId: msg.session.id }); + session.setActiveSession(data.session); + if (data.session) { + postMessage({ type: "getMessages", sessionId: data.session.id }); } else { - setMessages([]); + msg.setMessages([]); } break; case "event": - handleEvent(msg.event); + handleEvent(data.event); break; case "providers": { - setProviders(msg.providers); - setAllProvidersData(msg.allProviders); + prov.setProviders(data.providers); + prov.setAllProvidersData(data.allProviders); // サーバーのモデル設定を反映する(config.model → default の順でフォールバック) - setSelectedModel(() => { + prov.setSelectedModel(() => { const modelStr = - msg.configModel || msg.default.general || msg.default.code || Object.values(msg.default)[0]; + data.configModel || data.default.general || data.default.code || Object.values(data.default)[0]; if (!modelStr) return null; const slashIndex = modelStr.indexOf("/"); if (slashIndex < 0) return null; @@ -194,23 +96,23 @@ export function App() { break; } case "openEditors": - setOpenEditors(msg.files); + setOpenEditors(data.files); break; case "workspaceFiles": - setWorkspaceFiles(msg.files); + setWorkspaceFiles(data.files); break; case "toolConfig": - setOpenCodePaths(msg.paths); + setOpenCodePaths(data.paths); break; case "locale": - setVscodeLanguage(msg.vscodeLanguage); + locale.setVscodeLanguage(data.vscodeLanguage); break; case "modelUpdated": { - const slashIndex = msg.model.indexOf("/"); + const slashIndex = data.model.indexOf("/"); if (slashIndex >= 0) { - setSelectedModel({ - providerID: msg.model.slice(0, slashIndex), - modelID: msg.model.slice(slashIndex + 1), + prov.setSelectedModel({ + providerID: data.model.slice(0, slashIndex), + modelID: data.model.slice(slashIndex + 1), }); } break; @@ -221,191 +123,178 @@ export function App() { postMessage({ type: "ready" }); postMessage({ type: "getOpenEditors" }); return () => window.removeEventListener("message", handler); - }, [activeSession?.id, handleEvent]); + }, [session.activeSession?.id, handleEvent, session, msg, prov, perm, locale]); + + // Cross-cutting action handlers (span multiple hooks) const handleSend = useCallback( (text: string, files: FileAttachment[]) => { - if (!activeSession) return; + if (!session.activeSession) return; postMessage({ type: "sendMessage", - sessionId: activeSession.id, + sessionId: session.activeSession.id, text, - model: selectedModel ?? undefined, - files: files.length > 0 ? files : undefined, - }); - }, - [activeSession, selectedModel], +model: prov.selectedModel ?? undefined, + files: files.length > 0 ? files : undefined, + }); + }, + [session.activeSession, prov.selectedModel], ); - const handleNewSession = useCallback(() => { - postMessage({ type: "createSession" }); - setShowSessionList(false); - }, []); - - const handleSelectSession = useCallback((sessionId: string) => { - postMessage({ type: "selectSession", sessionId }); - setShowSessionList(false); - }, []); - - const handleDeleteSession = useCallback((sessionId: string) => { - postMessage({ type: "deleteSession", sessionId }); - }, []); - - const handleModelSelect = useCallback((model: { providerID: string; modelID: string }) => { - setSelectedModel(model); - postMessage({ type: "setModel", model: `${model.providerID}/${model.modelID}` }); - }, []); - const handleAbort = useCallback(() => { - if (!activeSession) return; - postMessage({ type: "abort", sessionId: activeSession.id }); - }, [activeSession]); + if (!session.activeSession) return; + postMessage({ type: "abort", sessionId: session.activeSession.id }); + }, [session.activeSession]); const handleCompress = useCallback(() => { - if (!activeSession) return; + if (!session.activeSession) return; postMessage({ type: "compressSession", - sessionId: activeSession.id, - model: selectedModel ?? undefined, + sessionId: session.activeSession.id, + model: prov.selectedModel ?? undefined, }); - }, [activeSession, selectedModel]); - - const handleOpenConfigFile = useCallback((filePath: string) => { - postMessage({ type: "openConfigFile", filePath }); - }, []); - - const handleOpenTerminal = useCallback(() => { - postMessage({ type: "openTerminal" }); - }, []); + }, [session.activeSession, prov.selectedModel]); // ユーザーメッセージを編集して再送信する const handleEditAndResend = useCallback( (messageId: string, text: string) => { - if (!activeSession) return; + if (!session.activeSession) return; // messageId は編集対象のユーザーメッセージ。 // その直前のメッセージまで巻き戻し、編集後のテキストを送信する。 - const msgIndex = messages.findIndex((m) => m.info.id === messageId); + const msgIndex = msg.messages.findIndex((m) => m.info.id === messageId); if (msgIndex < 0) return; if (msgIndex === 0) { // 最初のメッセージの場合: 新規セッションを作成して送信する方がクリーン // ただし revert API のフォールバックとして、messageId 自体で revert postMessage({ type: "editAndResend", - sessionId: activeSession.id, + sessionId: session.activeSession.id, messageId, text, - model: selectedModel ?? undefined, + model: prov.selectedModel ?? undefined, }); } else { // 直前のメッセージまで巻き戻して再送信 - const prevMessageId = messages[msgIndex - 1].info.id; + const prevMessageId = msg.messages[msgIndex - 1].info.id; postMessage({ type: "editAndResend", - sessionId: activeSession.id, + sessionId: session.activeSession.id, messageId: prevMessageId, text, - model: selectedModel ?? undefined, + model: prov.selectedModel ?? undefined, }); } }, - [activeSession, messages, selectedModel], + [session.activeSession, msg.messages, prov.selectedModel], ); // チェックポイントまで巻き戻す + ユーザーメッセージのテキストを入力欄に復元 const handleRevertToCheckpoint = useCallback( (assistantMessageId: string, userText: string | null) => { - if (!activeSession) return; + if (!session.activeSession) return; postMessage({ type: "revertToMessage", - sessionId: activeSession.id, + sessionId: session.activeSession.id, messageId: assistantMessageId, }); // ユーザーメッセージのテキストを入力欄にプリフィルする - setPrefillText(userText ?? ""); + msg.setPrefillText(userText ?? ""); }, - [activeSession], + [session.activeSession, msg], ); - // メッセージから最新の ToDo リストを導出(todowrite/todoread ツールの最新の出力) - const latestTodos = useMemo(() => { - for (let mi = messages.length - 1; mi >= 0; mi--) { - const parts = messages[mi].parts; - for (let pi = parts.length - 1; pi >= 0; pi--) { - const p = parts[pi]; - if (p.type !== "tool") continue; - if (p.tool !== "todowrite" && p.tool !== "todoread") continue; - const st = p.state; - if (st.status === "completed" && st.output) { - const parsed = parseTodos(st.output); - if (parsed) return parsed; - } - if (st.status !== "pending") { - const input = st.input as Record | undefined; - if (input) { - const parsed = parseTodos(input.todos ?? input); - if (parsed) return parsed; - } - } - } - } - return []; - }, [messages]); + const contextValue: AppContextValue = { + sessions: session.sessions, + activeSession: session.activeSession, + sessionBusy: session.sessionBusy, + showSessionList: session.showSessionList, + onNewSession: session.handleNewSession, + onSelectSession: session.handleSelectSession, + onDeleteSession: session.handleDeleteSession, + onToggleSessionList: session.toggleSessionList, + messages: msg.messages, + inputTokens: msg.inputTokens, + latestTodos: msg.latestTodos, + prefillText: msg.prefillText, + onPrefillConsumed: msg.consumePrefill, + providers: prov.providers, + allProvidersData: prov.allProvidersData, + selectedModel: prov.selectedModel, + onModelSelect: prov.handleModelSelect, + contextLimit: prov.contextLimit, + permissions: perm.permissions, + openEditors, + workspaceFiles, + onSend: handleSend, + onAbort: handleAbort, + onCompress: handleCompress, + isCompressing: !!session.activeSession?.time?.compacting, + onEditAndResend: handleEditAndResend, + onRevertToCheckpoint: handleRevertToCheckpoint, + openCodePaths, + onOpenConfigFile: handleOpenConfigFile, + onOpenTerminal: handleOpenTerminal, + localeSetting: locale.localeSetting, + onLocaleSettingChange: locale.handleLocaleSettingChange, + }; return ( - -
    - setShowSessionList((s) => !s)} - /> - {showSessionList && ( - setShowSessionList(false)} + + +
    + - )} - {activeSession ? ( - <> - - {latestTodos.length > 0 && } - setPrefillText("")} - openCodePaths={openCodePaths} - onOpenConfigFile={handleOpenConfigFile} - onOpenTerminal={handleOpenTerminal} - localeSetting={localeSetting} - onLocaleSettingChange={handleLocaleSettingChange} + {session.showSessionList && ( + - - ) : ( - - )} -
    + )} + {session.activeSession ? ( + <> + + {msg.latestTodos.length > 0 && } + + + ) : ( + + )} +
    +
    ); } diff --git a/webview/__tests__/hooks/useLocale.test.ts b/webview/__tests__/hooks/useLocale.test.ts new file mode 100644 index 0000000..8e9fb27 --- /dev/null +++ b/webview/__tests__/hooks/useLocale.test.ts @@ -0,0 +1,62 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useLocale } from "../../hooks/useLocale"; + +describe("useLocale", () => { + // initial state + context("初期状態の場合", () => { + // localeSetting defaults to "auto" + it("localeSetting が 'auto' であること", () => { + const { result } = renderHook(() => useLocale()); + expect(result.current.localeSetting).toBe("auto"); + }); + + // vscodeLanguage defaults to "en" + it("vscodeLanguage が 'en' であること", () => { + const { result } = renderHook(() => useLocale()); + expect(result.current.vscodeLanguage).toBe("en"); + }); + + // strings is an object (locale strings loaded) + it("strings がオブジェクトであること", () => { + const { result } = renderHook(() => useLocale()); + expect(typeof result.current.strings).toBe("object"); + }); + }); + + // handleLocaleSettingChange + context("handleLocaleSettingChange を呼んだ場合", () => { + // updates localeSetting + it("localeSetting が更新されること", () => { + const { result } = renderHook(() => useLocale()); + act(() => result.current.handleLocaleSettingChange("ja")); + expect(result.current.localeSetting).toBe("ja"); + }); + + // changes resolved strings when set to "ja" + it("strings が日本語に切り替わること", () => { + const { result } = renderHook(() => useLocale()); + act(() => result.current.handleLocaleSettingChange("ja")); + // resolvedLocale should be "ja" + expect(result.current.resolvedLocale).toBe("ja"); + }); + }); + + // setVscodeLanguage + context("setVscodeLanguage で言語を設定した場合", () => { + // updates vscodeLanguage + it("vscodeLanguage が更新されること", () => { + const { result } = renderHook(() => useLocale()); + act(() => result.current.setVscodeLanguage("ja")); + expect(result.current.vscodeLanguage).toBe("ja"); + }); + + // resolves auto setting to vscodeLanguage + it("localeSetting が auto の場合に vscodeLanguage に基づいてロケールを解決すること", () => { + const { result } = renderHook(() => useLocale()); + // localeSetting is "auto" by default + act(() => result.current.setVscodeLanguage("ja")); + expect(result.current.resolvedLocale).toBe("ja"); + }); + }); +}); diff --git a/webview/__tests__/hooks/useMessages.test.ts b/webview/__tests__/hooks/useMessages.test.ts new file mode 100644 index 0000000..cfaa901 --- /dev/null +++ b/webview/__tests__/hooks/useMessages.test.ts @@ -0,0 +1,180 @@ +import type { Event } from "@opencode-ai/sdk"; +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useMessages, type MessageWithParts } from "../../hooks/useMessages"; + +describe("useMessages", () => { + // initial state + context("初期状態の場合", () => { + // messages is empty + it("messages が空配列であること", () => { + const { result } = renderHook(() => useMessages()); + expect(result.current.messages).toEqual([]); + }); + + // inputTokens is 0 + it("inputTokens が 0 であること", () => { + const { result } = renderHook(() => useMessages()); + expect(result.current.inputTokens).toBe(0); + }); + + // latestTodos is empty + it("latestTodos が空配列であること", () => { + const { result } = renderHook(() => useMessages()); + expect(result.current.latestTodos).toEqual([]); + }); + + // prefillText is empty + it("prefillText が空文字であること", () => { + const { result } = renderHook(() => useMessages()); + expect(result.current.prefillText).toBe(""); + }); + }); + + // setMessages + context("setMessages で直接設定した場合", () => { + // sets messages + it("messages が設定されること", () => { + const { result } = renderHook(() => useMessages()); + const msg = { info: { id: "m1" }, parts: [] } as unknown as MessageWithParts; + act(() => result.current.setMessages([msg])); + expect(result.current.messages).toHaveLength(1); + }); + }); + + // inputTokens derivation + context("messages に step-finish パートがある場合", () => { + // sums input tokens from step-finish parts + it("inputTokens がトークン合計値を返すこと", () => { + const { result } = renderHook(() => useMessages()); + const msgs: MessageWithParts[] = [ + { + info: { id: "m1" } as any, + parts: [ + { id: "p1", type: "step-finish", tokens: { input: 100, output: 50 } } as any, + { id: "p2", type: "step-finish", tokens: { input: 200, output: 80 } } as any, + ], + }, + ]; + act(() => result.current.setMessages(msgs)); + expect(result.current.inputTokens).toBe(300); + }); + }); + + // prefill management + context("setPrefillText で値を設定した場合", () => { + // consumePrefill clears the text + it("consumePrefill で空文字にリセットされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.setPrefillText("hello")); + act(() => result.current.consumePrefill()); + expect(result.current.prefillText).toBe(""); + }); + }); + + // handleMessageEvent for message.updated + context("message.updated イベントを受信した場合", () => { + // adds new message when not existing + it("新しいメッセージを追加すること", () => { + const { result } = renderHook(() => useMessages()); + const event = { + type: "message.updated", + properties: { info: { id: "m1", role: "user" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.messages).toHaveLength(1); + }); + + // updates existing message info + it("既存メッセージの info を更新すること", () => { + const { result } = renderHook(() => useMessages()); + const msg: MessageWithParts = { info: { id: "m1", role: "user" } as any, parts: [] }; + act(() => result.current.setMessages([msg])); + const event = { + type: "message.updated", + properties: { info: { id: "m1", role: "user", metadata: { summary: "updated" } } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect((result.current.messages[0].info as any).metadata.summary).toBe("updated"); + }); + }); + + // handleMessageEvent for message.part.updated + context("message.part.updated イベントを受信した場合", () => { + // adds new part to existing message + it("既存メッセージに新しいパートを追加すること", () => { + const { result } = renderHook(() => useMessages()); + const msg: MessageWithParts = { info: { id: "m1" } as any, parts: [] }; + act(() => result.current.setMessages([msg])); + const event = { + type: "message.part.updated", + properties: { part: { id: "p1", messageID: "m1", type: "text", text: "hello" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.messages[0].parts).toHaveLength(1); + }); + + // updates existing part in message + it("既存パートを更新すること", () => { + const { result } = renderHook(() => useMessages()); + const msg: MessageWithParts = { + info: { id: "m1" } as any, + parts: [{ id: "p1", messageID: "m1", type: "text", text: "old" } as any], + }; + act(() => result.current.setMessages([msg])); + const event = { + type: "message.part.updated", + properties: { part: { id: "p1", messageID: "m1", type: "text", text: "new" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect((result.current.messages[0].parts[0] as any).text).toBe("new"); + }); + }); + + // handleMessageEvent for message.removed + context("message.removed イベントを受信した場合", () => { + // removes the message + it("該当メッセージを削除すること", () => { + const { result } = renderHook(() => useMessages()); + const msgs: MessageWithParts[] = [ + { info: { id: "m1" } as any, parts: [] }, + { info: { id: "m2" } as any, parts: [] }, + ]; + act(() => result.current.setMessages(msgs)); + const event = { + type: "message.removed", + properties: { messageID: "m1" }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.messages).toHaveLength(1); + }); + }); + + // latestTodos derivation + context("messages に todowrite ツールの完了出力がある場合", () => { + // parses todos from completed tool output + it("latestTodos にパース結果を返すこと", () => { + const { result } = renderHook(() => useMessages()); + const msgs: MessageWithParts[] = [ + { + info: { id: "m1" } as any, + parts: [ + { + id: "p1", + messageID: "m1", + type: "tool", + tool: "todowrite", + state: { + status: "completed", + output: JSON.stringify([{ content: "task1", status: "done" }]), + input: {}, + }, + } as any, + ], + }, + ]; + act(() => result.current.setMessages(msgs)); + expect(result.current.latestTodos).toEqual([{ content: "task1", status: "done" }]); + }); + }); +}); diff --git a/webview/__tests__/hooks/usePermissions.test.ts b/webview/__tests__/hooks/usePermissions.test.ts new file mode 100644 index 0000000..ee6fddd --- /dev/null +++ b/webview/__tests__/hooks/usePermissions.test.ts @@ -0,0 +1,73 @@ +import type { Event } from "@opencode-ai/sdk"; +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePermissions } from "../../hooks/usePermissions"; + +describe("usePermissions", () => { + // initial state + context("初期状態の場合", () => { + // permissions is empty + it("permissions が空の Map であること", () => { + const { result } = renderHook(() => usePermissions()); + expect(result.current.permissions.size).toBe(0); + }); + }); + + // permission.updated + context("permission.updated イベントを受信した場合", () => { + // adds permission to the map + it("permissions に追加されること", () => { + const { result } = renderHook(() => usePermissions()); + const event = { + type: "permission.updated", + properties: { id: "perm1", title: "allow bash" }, + } as unknown as Event; + act(() => result.current.handlePermissionEvent(event)); + expect(result.current.permissions.has("perm1")).toBe(true); + }); + + // stores the permission data + it("permission のデータが保持されること", () => { + const { result } = renderHook(() => usePermissions()); + const permission = { id: "perm1", title: "allow bash" }; + const event = { type: "permission.updated", properties: permission } as unknown as Event; + act(() => result.current.handlePermissionEvent(event)); + expect(result.current.permissions.get("perm1")).toEqual(permission); + }); + }); + + // permission.replied + context("permission.replied イベントを受信した場合", () => { + // removes permission from the map + it("permissions から削除されること", () => { + const { result } = renderHook(() => usePermissions()); + // first add + const addEvent = { + type: "permission.updated", + properties: { id: "perm1", title: "allow bash" }, + } as unknown as Event; + act(() => result.current.handlePermissionEvent(addEvent)); + // then reply + const replyEvent = { + type: "permission.replied", + properties: { permissionID: "perm1" }, + } as unknown as Event; + act(() => result.current.handlePermissionEvent(replyEvent)); + expect(result.current.permissions.has("perm1")).toBe(false); + }); + }); + + // unrelated events + context("無関係なイベントを受信した場合", () => { + // does not change permissions + it("permissions が変わらないこと", () => { + const { result } = renderHook(() => usePermissions()); + const event = { + type: "session.updated", + properties: { id: "session1" }, + } as unknown as Event; + act(() => result.current.handlePermissionEvent(event)); + expect(result.current.permissions.size).toBe(0); + }); + }); +}); diff --git a/webview/__tests__/hooks/useProviders.test.ts b/webview/__tests__/hooks/useProviders.test.ts new file mode 100644 index 0000000..00e7841 --- /dev/null +++ b/webview/__tests__/hooks/useProviders.test.ts @@ -0,0 +1,99 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useProviders } from "../../hooks/useProviders"; +import { postMessage } from "../../vscode-api"; + +describe("useProviders", () => { + // initial state + context("初期状態の場合", () => { + // providers is empty + it("providers が空配列であること", () => { + const { result } = renderHook(() => useProviders()); + expect(result.current.providers).toEqual([]); + }); + + // selectedModel is null + it("selectedModel が null であること", () => { + const { result } = renderHook(() => useProviders()); + expect(result.current.selectedModel).toBeNull(); + }); + + // allProvidersData is null + it("allProvidersData が null であること", () => { + const { result } = renderHook(() => useProviders()); + expect(result.current.allProvidersData).toBeNull(); + }); + + // contextLimit is 0 + it("contextLimit が 0 であること", () => { + const { result } = renderHook(() => useProviders()); + expect(result.current.contextLimit).toBe(0); + }); + }); + + // handleModelSelect + context("handleModelSelect を呼んだ場合", () => { + // updates selectedModel + it("selectedModel が更新されること", () => { + const { result } = renderHook(() => useProviders()); + const model = { providerID: "openai", modelID: "gpt-4" }; + act(() => result.current.handleModelSelect(model)); + expect(result.current.selectedModel).toEqual(model); + }); + + // sends setModel message + it("setModel メッセージを送信すること", () => { + const { result } = renderHook(() => useProviders()); + const model = { providerID: "openai", modelID: "gpt-4" }; + act(() => result.current.handleModelSelect(model)); + expect(postMessage).toHaveBeenCalledWith({ type: "setModel", model: "openai/gpt-4" }); + }); + }); + + // contextLimit derivation + context("providers と selectedModel が設定されている場合", () => { + // calculates context limit from provider model + it("モデルのコンテキストリミットを返すこと", () => { + const { result } = renderHook(() => useProviders()); + const providers = [ + { + id: "openai", + models: { + "gpt-4": { limit: { context: 128000 } }, + }, + }, + ] as any[]; + act(() => { + result.current.setProviders(providers); + result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-4" }); + }); + expect(result.current.contextLimit).toBe(128000); + }); + }); + + // setters + context("setters でステートを更新する場合", () => { + // setProviders updates providers + it("setProviders で providers を設定できること", () => { + const { result } = renderHook(() => useProviders()); + const providers = [{ id: "anthropic", models: {} }] as any[]; + act(() => result.current.setProviders(providers)); + expect(result.current.providers).toEqual(providers); + }); + + // setAllProvidersData updates allProvidersData + it("setAllProvidersData で allProvidersData を設定できること", () => { + const { result } = renderHook(() => useProviders()); + const data = { anthropic: { name: "Anthropic", models: {} } } as any; + act(() => result.current.setAllProvidersData(data)); + expect(result.current.allProvidersData).toEqual(data); + }); + + // setSelectedModel with function updater + it("setSelectedModel に関数を渡してモデルを設定できること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setSelectedModel(() => ({ providerID: "openai", modelID: "gpt-4o" }))); + expect(result.current.selectedModel).toEqual({ providerID: "openai", modelID: "gpt-4o" }); + }); + }); +}); diff --git a/webview/__tests__/hooks/useSession.test.ts b/webview/__tests__/hooks/useSession.test.ts new file mode 100644 index 0000000..1aeb11b --- /dev/null +++ b/webview/__tests__/hooks/useSession.test.ts @@ -0,0 +1,157 @@ +import type { Event } from "@opencode-ai/sdk"; +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useSession } from "../../hooks/useSession"; +import { postMessage } from "../../vscode-api"; + +describe("useSession", () => { + // session list management + context("セッション一覧を管理する場合", () => { + // sets sessions via setSessions + it("setSessions でセッション一覧を設定できること", () => { + const { result } = renderHook(() => useSession()); + const session = { id: "s1" } as any; + act(() => result.current.setSessions([session])); + expect(result.current.sessions).toEqual([session]); + }); + + // sets activeSession via setActiveSession + it("setActiveSession でアクティブセッションを設定できること", () => { + const { result } = renderHook(() => useSession()); + const session = { id: "s1" } as any; + act(() => result.current.setActiveSession(session)); + expect(result.current.activeSession).toEqual(session); + }); + }); + + // handleNewSession action + context("handleNewSession を呼んだ場合", () => { + // sends createSession message + it("createSession メッセージを送信すること", () => { + const { result } = renderHook(() => useSession()); + act(() => result.current.handleNewSession()); + expect(postMessage).toHaveBeenCalledWith({ type: "createSession" }); + }); + + // closes session list + it("セッションリストを閉じること", () => { + const { result } = renderHook(() => useSession()); + act(() => result.current.toggleSessionList()); + act(() => result.current.handleNewSession()); + expect(result.current.showSessionList).toBe(false); + }); + }); + + // handleSelectSession action + context("handleSelectSession を呼んだ場合", () => { + // sends selectSession message + it("selectSession メッセージを送信すること", () => { + const { result } = renderHook(() => useSession()); + act(() => result.current.handleSelectSession("s1")); + expect(postMessage).toHaveBeenCalledWith({ type: "selectSession", sessionId: "s1" }); + }); + + // closes session list + it("セッションリストを閉じること", () => { + const { result } = renderHook(() => useSession()); + act(() => result.current.toggleSessionList()); + act(() => result.current.handleSelectSession("s1")); + expect(result.current.showSessionList).toBe(false); + }); + }); + + // handleDeleteSession action + context("handleDeleteSession を呼んだ場合", () => { + // sends deleteSession message + it("deleteSession メッセージを送信すること", () => { + const { result } = renderHook(() => useSession()); + act(() => result.current.handleDeleteSession("s1")); + expect(postMessage).toHaveBeenCalledWith({ type: "deleteSession", sessionId: "s1" }); + }); + }); + + // toggleSessionList action + context("toggleSessionList を呼んだ場合", () => { + // toggles showSessionList + it("showSessionList をトグルすること", () => { + const { result } = renderHook(() => useSession()); + expect(result.current.showSessionList).toBe(false); + act(() => result.current.toggleSessionList()); + expect(result.current.showSessionList).toBe(true); + }); + }); + + // handleSessionEvent for session.status + context("session.status イベントを受信した場合", () => { + // sets sessionBusy to true when busy + it("busy のとき sessionBusy が true になること", () => { + const { result } = renderHook(() => useSession()); + const event = { type: "session.status", properties: { status: { type: "busy" } } } as unknown as Event; + act(() => result.current.handleSessionEvent(event)); + expect(result.current.sessionBusy).toBe(true); + }); + + // sets sessionBusy to false when idle + it("idle のとき sessionBusy が false になること", () => { + const { result } = renderHook(() => useSession()); + const busyEvent = { type: "session.status", properties: { status: { type: "busy" } } } as unknown as Event; + act(() => result.current.handleSessionEvent(busyEvent)); + const idleEvent = { type: "session.status", properties: { status: { type: "idle" } } } as unknown as Event; + act(() => result.current.handleSessionEvent(idleEvent)); + expect(result.current.sessionBusy).toBe(false); + }); + }); + + // handleSessionEvent for session.updated + context("session.updated イベントを受信した場合", () => { + // updates matching session in sessions list + it("sessions 内の該当セッションを更新すること", () => { + const { result } = renderHook(() => useSession()); + const s1 = { id: "s1", title: "old" } as any; + act(() => result.current.setSessions([s1])); + const updated = { id: "s1", title: "new" } as any; + const event = { type: "session.updated", properties: { info: updated } } as unknown as Event; + act(() => result.current.handleSessionEvent(event)); + expect(result.current.sessions[0].title).toBe("new"); + }); + + // updates activeSession if it matches + it("アクティブセッションが該当する場合は更新すること", () => { + const { result } = renderHook(() => useSession()); + const s1 = { id: "s1", title: "old" } as any; + act(() => result.current.setActiveSession(s1)); + const updated = { id: "s1", title: "new" } as any; + const event = { type: "session.updated", properties: { info: updated } } as unknown as Event; + act(() => result.current.handleSessionEvent(event)); + expect(result.current.activeSession?.title).toBe("new"); + }); + }); + + // handleSessionEvent for session.created + context("session.created イベントを受信した場合", () => { + // prepends new session to sessions list + it("sessions の先頭に新しいセッションを追加すること", () => { + const { result } = renderHook(() => useSession()); + const existing = { id: "s1" } as any; + act(() => result.current.setSessions([existing])); + const newSession = { id: "s2" } as any; + const event = { type: "session.created", properties: { info: newSession } } as unknown as Event; + act(() => result.current.handleSessionEvent(event)); + expect(result.current.sessions[0].id).toBe("s2"); + }); + }); + + // handleSessionEvent for session.deleted + context("session.deleted イベントを受信した場合", () => { + // removes the deleted session from sessions list + it("sessions から該当セッションを削除すること", () => { + const { result } = renderHook(() => useSession()); + const s1 = { id: "s1" } as any; + const s2 = { id: "s2" } as any; + act(() => result.current.setSessions([s1, s2])); + const event = { type: "session.deleted", properties: { info: { id: "s1" } } } as unknown as Event; + act(() => result.current.handleSessionEvent(event)); + expect(result.current.sessions).toHaveLength(1); + }); + }); +}); diff --git a/webview/contexts/AppContext.tsx b/webview/contexts/AppContext.tsx new file mode 100644 index 0000000..bd31e1b --- /dev/null +++ b/webview/contexts/AppContext.tsx @@ -0,0 +1,68 @@ +import type { Permission, Provider, Session } from "@opencode-ai/sdk"; +import { createContext, useContext } from "react"; +import type { MessageWithParts } from "../hooks/useMessages"; +import type { LocaleSetting } from "../locales"; +import type { AllProvidersData, FileAttachment } from "../vscode-api"; +import type { TodoItem } from "../utils/todo"; + +export type AppContextValue = { + // Session + sessions: Session[]; + activeSession: Session | null; + sessionBusy: boolean; + showSessionList: boolean; + onNewSession: () => void; + onSelectSession: (sessionId: string) => void; + onDeleteSession: (sessionId: string) => void; + onToggleSessionList: () => void; + + // Messages + messages: MessageWithParts[]; + inputTokens: number; + latestTodos: TodoItem[]; + prefillText: string; + onPrefillConsumed: () => void; + + // Providers & Model + providers: Provider[]; + allProvidersData: AllProvidersData | null; + selectedModel: { providerID: string; modelID: string } | null; + onModelSelect: (model: { providerID: string; modelID: string }) => void; + contextLimit: number; + + // Permissions + permissions: Map; + + // Files + openEditors: FileAttachment[]; + workspaceFiles: FileAttachment[]; + + // Actions + onSend: (text: string, files: FileAttachment[]) => void; + onAbort: () => void; + onCompress: () => void; + isCompressing: boolean; + onEditAndResend: (messageId: string, text: string) => void; + onRevertToCheckpoint: (assistantMessageId: string, userText: string | null) => void; + + // Settings + openCodePaths: { home: string; config: string; state: string; directory: string } | null; + onOpenConfigFile: (filePath: string) => void; + onOpenTerminal: () => void; + localeSetting: LocaleSetting; + onLocaleSettingChange: (setting: LocaleSetting) => void; +}; + +// Context は AppProvider 内でのみ使用される想定。 +// Provider 外で useAppContext() を呼んだ場合はランタイムエラーで即座に気づけるよう null を初期値とする。 +const AppContext = createContext(null); + +export const AppContextProvider = AppContext.Provider; + +export function useAppContext(): AppContextValue { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error("useAppContext must be used within AppContextProvider"); + } + return ctx; +} diff --git a/webview/hooks/useLocale.ts b/webview/hooks/useLocale.ts new file mode 100644 index 0000000..0b28060 --- /dev/null +++ b/webview/hooks/useLocale.ts @@ -0,0 +1,28 @@ +import { useCallback, useMemo, useState } from "react"; +import type { LocaleSetting } from "../locales"; +import { getStrings, resolveLocale } from "../locales"; +import { getPersistedState, setPersistedState } from "../vscode-api"; + +export function useLocale() { + const [localeSetting, setLocaleSetting] = useState( + () => (getPersistedState()?.localeSetting as LocaleSetting) ?? "auto", + ); + const [vscodeLanguage, setVscodeLanguage] = useState("en"); + + const resolvedLocale = useMemo(() => resolveLocale(localeSetting, vscodeLanguage), [localeSetting, vscodeLanguage]); + const strings = useMemo(() => getStrings(resolvedLocale), [resolvedLocale]); + + const handleLocaleSettingChange = useCallback((setting: LocaleSetting) => { + setLocaleSetting(setting); + setPersistedState({ ...getPersistedState(), localeSetting: setting }); + }, []); + + return { + localeSetting, + vscodeLanguage, + setVscodeLanguage, + resolvedLocale, + strings, + handleLocaleSettingChange, + } as const; +} diff --git a/webview/hooks/useMessages.ts b/webview/hooks/useMessages.ts new file mode 100644 index 0000000..0a2ccfb --- /dev/null +++ b/webview/hooks/useMessages.ts @@ -0,0 +1,120 @@ +import type { Event, Message, Part } from "@opencode-ai/sdk"; +import { useCallback, useMemo, useState } from "react"; +import type { TodoItem } from "../utils/todo"; +import { parseTodos } from "../utils/todo"; + +export type MessageWithParts = { info: Message; parts: Part[] }; + +/** メッセージ情報を追加または更新する */ +function upsertMessage(prev: MessageWithParts[], info: Message): MessageWithParts[] { + const idx = prev.findIndex((m) => m.info.id === info.id); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], info }; + return updated; + } + return [...prev, { info, parts: [] }]; +} + +/** パートを追加または更新する */ +function upsertPart(prev: MessageWithParts[], part: Part): MessageWithParts[] { + const idx = prev.findIndex((m) => m.info.id === part.messageID); + if (idx < 0) return prev; + const updated = [...prev]; + const msg = { ...updated[idx] }; + const partIdx = msg.parts.findIndex((p) => p.id === part.id); + if (partIdx >= 0) { + msg.parts = [...msg.parts]; + msg.parts[partIdx] = part; + } else { + msg.parts = [...msg.parts, part]; + } + updated[idx] = msg; + return updated; +} + +/** メッセージを削除する */ +function removeMessage(prev: MessageWithParts[], messageID: string): MessageWithParts[] { + return prev.filter((m) => m.info.id !== messageID); +} + +/** + * チャットメッセージの状態管理フック。 + * + * メッセージの実体はサーバー側が保持しているが、AI の応答中はテキストやツール呼び出しの + * パートが SSE で細かく差分配信されるため、Webview 側でも配列を保持して差分マージすることで + * ストリーミング表示をリアルタイムに実現している。 + * また、この配列から inputTokens(コンテキスト使用量)や latestTodos を導出している。 + */ +export function useMessages() { + const [messages, setMessages] = useState([]); + const [prefillText, setPrefillText] = useState(""); + + // messages から StepFinishPart のトークン使用量を導出する(圧縮でメッセージが減ると自動的に反映される) + const inputTokens = useMemo(() => { + let total = 0; + for (const m of messages) { + for (const p of m.parts) { + if (p.type === "step-finish" && p.tokens) { + total += p.tokens.input; + } + } + } + return total; + }, [messages]); + + // メッセージから最新の ToDo リストを導出(todowrite/todoread ツールの最新の出力) + const latestTodos = useMemo(() => { + for (let mi = messages.length - 1; mi >= 0; mi--) { + const parts = messages[mi].parts; + for (let pi = parts.length - 1; pi >= 0; pi--) { + const p = parts[pi]; + if (p.type !== "tool") continue; + if (p.tool !== "todowrite" && p.tool !== "todoread") continue; + const st = p.state; + if (st.status === "completed" && st.output) { + const parsed = parseTodos(st.output); + if (parsed) return parsed; + } + if (st.status !== "pending") { + const input = st.input as Record | undefined; + if (input) { + const parsed = parseTodos(input.todos ?? input); + if (parsed) return parsed; + } + } + } + } + return []; + }, [messages]); + + const consumePrefill = useCallback(() => { + setPrefillText(""); + }, []); + + // SSE event handler for message-related events + const handleMessageEvent = useCallback((event: Event) => { + switch (event.type) { + case "message.updated": + setMessages((prev) => upsertMessage(prev, event.properties.info)); + break; + case "message.part.updated": + setMessages((prev) => upsertPart(prev, event.properties.part)); + break; + case "message.removed": + setMessages((prev) => removeMessage(prev, event.properties.messageID)); + break; + } + }, []); + + return { + messages, + setMessages, + prefillText, + setPrefillText, + inputTokens, + latestTodos, + consumePrefill, + handleMessageEvent, + } as const; +} diff --git a/webview/hooks/usePermissions.ts b/webview/hooks/usePermissions.ts new file mode 100644 index 0000000..36727dd --- /dev/null +++ b/webview/hooks/usePermissions.ts @@ -0,0 +1,45 @@ +import type { Event, Permission } from "@opencode-ai/sdk"; +import { useCallback, useState } from "react"; + +/** + * ツール実行時の許可リクエスト(Allow / Once / Deny)の状態管理フック。 + * + * AI がツールを使おうとすると permission.updated で許可リクエストが届き、 + * ユーザーが回答すると permission.replied で解消される。 + * Map が空でなければ未回答のリクエストがあり、PermissionView が表示される。 + */ +export function usePermissions() { + const [permissions, setPermissions] = useState>(new Map()); + + const addPermission = useCallback((permission: Permission) => { + setPermissions((prev) => { + const next = new Map(prev); + next.set(permission.id, permission); + return next; + }); + }, []); + + const removePermission = useCallback((permissionID: string) => { + setPermissions((prev) => { + const next = new Map(prev); + next.delete(permissionID); + return next; + }); + }, []); + + const handlePermissionEvent = useCallback((event: Event) => { + switch (event.type) { + case "permission.updated": + addPermission(event.properties); + break; + case "permission.replied": + removePermission(event.properties.permissionID); + break; + } + }, [addPermission, removePermission]); + + return { + permissions, + handlePermissionEvent, + } as const; +} diff --git a/webview/hooks/useProviders.ts b/webview/hooks/useProviders.ts new file mode 100644 index 0000000..4f6ec3a --- /dev/null +++ b/webview/hooks/useProviders.ts @@ -0,0 +1,35 @@ +import type { Provider } from "@opencode-ai/sdk"; +import { useCallback, useMemo, useState } from "react"; +import type { AllProvidersData } from "../vscode-api"; +import { postMessage } from "../vscode-api"; + +export function useProviders() { + const [providers, setProviders] = useState([]); + const [allProvidersData, setAllProvidersData] = useState(null); + const [selectedModel, setSelectedModel] = useState<{ providerID: string; modelID: string } | null>(null); + + // 選択中のモデルのコンテキストリミットを算出 + const contextLimit = useMemo(() => { + if (!selectedModel) return 0; + const provider = providers.find((p) => p.id === selectedModel.providerID); + if (!provider) return 0; + const model = provider.models[selectedModel.modelID]; + return model?.limit?.context ?? 0; + }, [providers, selectedModel]); + + const handleModelSelect = useCallback((model: { providerID: string; modelID: string }) => { + setSelectedModel(model); + postMessage({ type: "setModel", model: `${model.providerID}/${model.modelID}` }); + }, []); + + return { + providers, + setProviders, + allProvidersData, + setAllProvidersData, + selectedModel, + setSelectedModel, + contextLimit, + handleModelSelect, + } as const; +} diff --git a/webview/hooks/useSession.ts b/webview/hooks/useSession.ts new file mode 100644 index 0000000..da638a8 --- /dev/null +++ b/webview/hooks/useSession.ts @@ -0,0 +1,67 @@ +import type { Event, Session } from "@opencode-ai/sdk"; +import { useCallback, useState } from "react"; +import { postMessage } from "../vscode-api"; + +export function useSession() { + const [sessions, setSessions] = useState([]); + const [activeSession, setActiveSession] = useState(null); + const [sessionBusy, setSessionBusy] = useState(false); + const [showSessionList, setShowSessionList] = useState(false); + + const handleNewSession = useCallback(() => { + postMessage({ type: "createSession" }); + setShowSessionList(false); + }, []); + + const handleSelectSession = useCallback((sessionId: string) => { + postMessage({ type: "selectSession", sessionId }); + setShowSessionList(false); + }, []); + + const handleDeleteSession = useCallback((sessionId: string) => { + postMessage({ type: "deleteSession", sessionId }); + }, []); + + const toggleSessionList = useCallback(() => { + setShowSessionList((s) => !s); + }, []); + + // SSE event handler for session-related events + const handleSessionEvent = useCallback((event: Event) => { + switch (event.type) { + case "session.status": { + setSessionBusy(event.properties.status.type === "busy"); + break; + } + case "session.updated": { + const info = event.properties.info; + setSessions((prev) => prev.map((s) => (s.id === info.id ? info : s))); + setActiveSession((prev) => (prev?.id === info.id ? info : prev)); + break; + } + case "session.created": { + setSessions((prev) => [event.properties.info, ...prev]); + break; + } + case "session.deleted": { + const deletedId = event.properties.info.id; + setSessions((prev) => prev.filter((s) => s.id !== deletedId)); + break; + } + } + }, []); + + return { + sessions, + setSessions, + activeSession, + setActiveSession, + sessionBusy, + showSessionList, + toggleSessionList, + handleNewSession, + handleSelectSession, + handleDeleteSession, + handleSessionEvent, + } as const; +} From ee6294d72e34c58aba671adc11a476888c602e57 Mon Sep 17 00:00:00 2001 From: ktmage Date: Thu, 26 Feb 2026 04:01:50 +0900 Subject: [PATCH 10/84] feat: add TodoHeader and TodoView components for task management - Implemented TodoHeader component to display summary and toggle list of todos. - Created TodoView component to list individual todos with their status and priority. - Added ChatHeader component for managing chat sessions with new session and toggle actions. - Developed InputArea component for user input with file attachment and model selection features. - Introduced MessageItem and MessagesArea components for displaying chat messages and interactions. - Implemented PermissionView for handling permission requests in chat. - Added SessionList component for managing chat sessions with delete and select functionality. - Created ToolConfigPanel for configuring tool settings and managing locale options. - Developed ToolPartView for displaying tool actions and outputs, including todo management. --- webview/App.tsx | 12 +- .../atoms/ContextIndicator.test.tsx | 103 +++++++++++++++++ .../atoms/StreamingIndicator.test.tsx | 20 ++++ .../components/molecules/DiffView.test.tsx | 50 ++++++++ .../components/molecules/EmptyState.test.tsx | 37 ++++++ .../molecules/FileAttachmentBar.test.tsx | 99 ++++++++++++++++ .../molecules/FileCreateView.test.tsx | 33 ++++++ .../molecules/HashFilePopup.test.tsx | 45 ++++++++ .../molecules/ModelSelector.test.tsx | 63 ++++++++++ .../molecules/TextPartView.test.tsx | 33 ++++++ .../components/molecules/TodoHeader.test.tsx | 63 ++++++++++ .../components/molecules/TodoView.test.tsx | 59 ++++++++++ .../components/organisms/ChatHeader.test.tsx | 61 ++++++++++ .../components/organisms/MessageItem.test.tsx | 74 ++++++++++++ .../organisms/MessagesArea.test.tsx | 66 +++++++++++ .../organisms/PermissionView.test.tsx | 66 +++++++++++ .../components/organisms/SessionList.test.tsx | 65 +++++++++++ .../organisms/ToolConfigPanel.test.tsx | 52 +++++++++ .../organisms/ToolPartView.test.tsx | 103 +++++++++++++++++ .../{ => atoms}/ContextIndicator.tsx | 4 +- .../{ => atoms}/StreamingIndicator.tsx | 0 webview/components/molecules/DiffView.tsx | 32 ++++++ .../components/{ => molecules}/EmptyState.tsx | 2 +- .../molecules/FileAttachmentBar.tsx | 97 ++++++++++++++++ .../components/molecules/FileCreateView.tsx | 26 +++++ .../components/molecules/HashFilePopup.tsx | 27 +++++ .../{ => molecules}/ModelSelector.tsx | 6 +- .../{ => molecules}/TextPartView.tsx | 0 .../components/{ => molecules}/TodoHeader.tsx | 6 +- webview/components/molecules/TodoView.tsx | 31 +++++ .../components/{ => organisms}/ChatHeader.tsx | 4 +- .../components/{ => organisms}/InputArea.tsx | 108 ++++-------------- .../{ => organisms}/MessageItem.tsx | 8 +- .../{ => organisms}/MessagesArea.tsx | 8 +- .../{ => organisms}/PermissionView.tsx | 4 +- .../{ => organisms}/SessionList.tsx | 4 +- .../{ => organisms}/ToolConfigPanel.tsx | 6 +- .../{ => organisms}/ToolPartView.tsx | 102 ++--------------- 38 files changed, 1370 insertions(+), 209 deletions(-) create mode 100644 webview/__tests__/components/atoms/ContextIndicator.test.tsx create mode 100644 webview/__tests__/components/atoms/StreamingIndicator.test.tsx create mode 100644 webview/__tests__/components/molecules/DiffView.test.tsx create mode 100644 webview/__tests__/components/molecules/EmptyState.test.tsx create mode 100644 webview/__tests__/components/molecules/FileAttachmentBar.test.tsx create mode 100644 webview/__tests__/components/molecules/FileCreateView.test.tsx create mode 100644 webview/__tests__/components/molecules/HashFilePopup.test.tsx create mode 100644 webview/__tests__/components/molecules/ModelSelector.test.tsx create mode 100644 webview/__tests__/components/molecules/TextPartView.test.tsx create mode 100644 webview/__tests__/components/molecules/TodoHeader.test.tsx create mode 100644 webview/__tests__/components/molecules/TodoView.test.tsx create mode 100644 webview/__tests__/components/organisms/ChatHeader.test.tsx create mode 100644 webview/__tests__/components/organisms/MessageItem.test.tsx create mode 100644 webview/__tests__/components/organisms/MessagesArea.test.tsx create mode 100644 webview/__tests__/components/organisms/PermissionView.test.tsx create mode 100644 webview/__tests__/components/organisms/SessionList.test.tsx create mode 100644 webview/__tests__/components/organisms/ToolConfigPanel.test.tsx create mode 100644 webview/__tests__/components/organisms/ToolPartView.test.tsx rename webview/components/{ => atoms}/ContextIndicator.tsx (97%) rename webview/components/{ => atoms}/StreamingIndicator.tsx (100%) create mode 100644 webview/components/molecules/DiffView.tsx rename webview/components/{ => molecules}/EmptyState.tsx (91%) create mode 100644 webview/components/molecules/FileAttachmentBar.tsx create mode 100644 webview/components/molecules/FileCreateView.tsx create mode 100644 webview/components/molecules/HashFilePopup.tsx rename webview/components/{ => molecules}/ModelSelector.tsx (98%) rename webview/components/{ => molecules}/TextPartView.tsx (100%) rename webview/components/{ => molecules}/TodoHeader.tsx (92%) create mode 100644 webview/components/molecules/TodoView.tsx rename webview/components/{ => organisms}/ChatHeader.tsx (89%) rename webview/components/{ => organisms}/InputArea.tsx (77%) rename webview/components/{ => organisms}/MessageItem.tsx (97%) rename webview/components/{ => organisms}/MessagesArea.tsx (93%) rename webview/components/{ => organisms}/PermissionView.tsx (91%) rename webview/components/{ => organisms}/SessionList.tsx (96%) rename webview/components/{ => organisms}/ToolConfigPanel.tsx (94%) rename webview/components/{ => organisms}/ToolPartView.tsx (57%) diff --git a/webview/App.tsx b/webview/App.tsx index 09d757c..f0da337 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,11 +1,11 @@ import type { Event } from "@opencode-ai/sdk"; import { useCallback, useEffect, useState } from "react"; -import { ChatHeader } from "./components/ChatHeader"; -import { EmptyState } from "./components/EmptyState"; -import { InputArea } from "./components/InputArea"; -import { MessagesArea } from "./components/MessagesArea"; -import { SessionList } from "./components/SessionList"; -import { TodoHeader } from "./components/TodoHeader"; +import { ChatHeader } from "./components/organisms/ChatHeader"; +import { EmptyState } from "./components/molecules/EmptyState"; +import { InputArea } from "./components/organisms/InputArea"; +import { MessagesArea } from "./components/organisms/MessagesArea"; +import { SessionList } from "./components/organisms/SessionList"; +import { TodoHeader } from "./components/molecules/TodoHeader"; import { AppContextProvider, type AppContextValue } from "./contexts/AppContext"; import { useLocale } from "./hooks/useLocale"; import { useMessages } from "./hooks/useMessages"; diff --git a/webview/__tests__/components/atoms/ContextIndicator.test.tsx b/webview/__tests__/components/atoms/ContextIndicator.test.tsx new file mode 100644 index 0000000..4b58fe3 --- /dev/null +++ b/webview/__tests__/components/atoms/ContextIndicator.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ContextIndicator, formatTokenCount } from "../../../components/atoms/ContextIndicator"; + +describe("formatTokenCount", () => { + // when value is less than 1000 + context("1000 未満の場合", () => { + // returns the number as-is + it("数値をそのまま文字列で返すこと", () => { + expect(formatTokenCount(500)).toBe("500"); + }); + }); + + // when value is in thousands + context("1000 以上 100 万未満の場合", () => { + // returns K notation + it("K 表記で返すこと", () => { + expect(formatTokenCount(1500)).toBe("1.5K"); + }); + }); + + // when value is in millions + context("100 万以上の場合", () => { + // returns M notation + it("M 表記で返すこと", () => { + expect(formatTokenCount(2_500_000)).toBe("2.5M"); + }); + }); +}); + +describe("ContextIndicator", () => { + const defaultProps = { + inputTokens: 5000, + contextLimit: 10000, + onCompress: vi.fn(), + isCompressing: false, + }; + + // when contextLimit is 0 or less + context("contextLimit が 0 以下の場合", () => { + // renders nothing + it("何もレンダリングしないこと", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + }); + + // when usage is 0% + context("使用率が 0% の場合", () => { + // renders nothing + it("何もレンダリングしないこと", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + }); + + // when usage is above 0% + context("使用率が 0% より大きい場合", () => { + // renders an SVG progress ring + it("SVG プログレスリングをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + // renders the indicator button + it("インジケーターボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".context-indicator-button")).toBeInTheDocument(); + }); + }); + + // when the button is clicked + context("ボタンをクリックした場合", () => { + // shows the popup + it("ポップアップを表示すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".context-indicator-button")!); + expect(container.querySelector(".context-indicator-popup")).toBeInTheDocument(); + }); + }); + + // when compress button is clicked in the popup + context("ポップアップ内の圧縮ボタンをクリックした場合", () => { + // calls onCompress + it("onCompress が呼ばれること", () => { + const onCompress = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector(".context-indicator-button")!); + fireEvent.click(container.querySelector(".context-indicator-compress-button")!); + expect(onCompress).toHaveBeenCalledOnce(); + }); + }); + + // when isCompressing is true + context("isCompressing が true の場合", () => { + // disables the compress button + it("圧縮ボタンが無効化されること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".context-indicator-button")!); + expect(container.querySelector(".context-indicator-compress-button")).toBeDisabled(); + }); + }); +}); diff --git a/webview/__tests__/components/atoms/StreamingIndicator.test.tsx b/webview/__tests__/components/atoms/StreamingIndicator.test.tsx new file mode 100644 index 0000000..da5cee7 --- /dev/null +++ b/webview/__tests__/components/atoms/StreamingIndicator.test.tsx @@ -0,0 +1,20 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StreamingIndicator } from "../../../components/atoms/StreamingIndicator"; + +describe("StreamingIndicator", () => { + // when rendered + context("レンダリングした場合", () => { + // renders the streaming-indicator container + it("streaming-indicator コンテナをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".streaming-indicator")).toBeInTheDocument(); + }); + + // renders exactly 3 dots + it("ドットを 3 つレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".streaming-dot")).toHaveLength(3); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/DiffView.test.tsx b/webview/__tests__/components/molecules/DiffView.test.tsx new file mode 100644 index 0000000..dad2a38 --- /dev/null +++ b/webview/__tests__/components/molecules/DiffView.test.tsx @@ -0,0 +1,50 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DiffView } from "../../../components/molecules/DiffView"; + +describe("DiffView", () => { + // when there are additions and removals + context("追加行と削除行がある場合", () => { + // renders add count + it("追加行数を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".tool-diff-stat-add")).toBeInTheDocument(); + }); + + // renders remove count + it("削除行数を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".tool-diff-stat-remove")).toBeInTheDocument(); + }); + + // renders diff lines + it("差分行をレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".tool-diff-line").length).toBeGreaterThan(0); + }); + }); + + // when strings are identical + context("文字列が同一の場合", () => { + // does not show add stat + it("追加行数を表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".tool-diff-stat-add")).not.toBeInTheDocument(); + }); + + // does not show remove stat + it("削除行数を表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".tool-diff-stat-remove")).not.toBeInTheDocument(); + }); + }); + + // when only additions exist + context("追加行のみの場合", () => { + // renders add stat + it("追加行数のみを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".tool-diff-stat-add")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/EmptyState.test.tsx b/webview/__tests__/components/molecules/EmptyState.test.tsx new file mode 100644 index 0000000..81d9997 --- /dev/null +++ b/webview/__tests__/components/molecules/EmptyState.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EmptyState } from "../../../components/molecules/EmptyState"; + +describe("EmptyState", () => { + // when rendered + context("レンダリングした場合", () => { + // renders the title + it("タイトルを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".empty-state-title")).toBeInTheDocument(); + }); + + // renders the description + it("説明文を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".empty-state-description")).toBeInTheDocument(); + }); + + // renders the new chat button + it("新規チャットボタンを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".btn")).toBeInTheDocument(); + }); + }); + + // when new chat button is clicked + context("新規チャットボタンをクリックした場合", () => { + // calls onNewSession + it("onNewSession が呼ばれること", () => { + const onNewSession = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector(".btn")!); + expect(onNewSession).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx b/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx new file mode 100644 index 0000000..89cf12c --- /dev/null +++ b/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FileAttachmentBar } from "../../../components/molecules/FileAttachmentBar"; +import type { FileAttachment } from "../../../vscode-api"; + +const file1: FileAttachment = { fileName: "app.tsx", filePath: "/src/app.tsx" }; +const file2: FileAttachment = { fileName: "index.ts", filePath: "/src/index.ts" }; + +function createRef() { + return { current: null } as React.RefObject; +} + +const defaultProps = { + attachedFiles: [] as FileAttachment[], + activeEditorFile: null, + isActiveAttached: false, + showFilePicker: false, + filePickerQuery: "", + pickerFiles: [] as FileAttachment[], + onClipClick: vi.fn(), + onFilePickerSearch: vi.fn(), + onAddFile: vi.fn(), + onRemoveFile: vi.fn(), + filePickerRef: createRef(), +}; + +describe("FileAttachmentBar", () => { + // when rendered with default props + context("デフォルト props の場合", () => { + // renders clip button + it("クリップボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".context-clip-button")).toBeInTheDocument(); + }); + }); + + // when clip button is clicked + context("クリップボタンをクリックした場合", () => { + // calls onClipClick + it("onClipClick が呼ばれること", () => { + const onClipClick = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector(".context-clip-button")!); + expect(onClipClick).toHaveBeenCalledOnce(); + }); + }); + + // when files are attached + context("ファイルが添付されている場合", () => { + // renders attached file chips + it("添付ファイルチップをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".attached-file-chip")).toHaveLength(2); + }); + + // calls onRemoveFile when remove button is clicked + it("削除ボタンクリックで onRemoveFile が呼ばれること", () => { + const onRemoveFile = vi.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector(".attached-file-remove")!); + expect(onRemoveFile).toHaveBeenCalledWith(file1.filePath); + }); + }); + + // when activeEditorFile is set and not attached + context("アクティブエディタファイルが設定され未添付の場合", () => { + // renders quick-add button + it("quick-add ボタンをレンダリングすること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".context-file-button")).toBeInTheDocument(); + }); + }); + + // when activeEditorFile is already attached + context("アクティブエディタファイルが既に添付済みの場合", () => { + // does not render quick-add button + it("quick-add ボタンをレンダリングしないこと", () => { + const { container } = render( + , + ); + expect(container.querySelector(".context-file-button")).not.toBeInTheDocument(); + }); + }); + + // when file picker is open + context("ファイルピッカーが開いている場合", () => { + // renders the dropdown + it("ドロップダウンをレンダリングすること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".file-picker-dropdown")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/FileCreateView.test.tsx b/webview/__tests__/components/molecules/FileCreateView.test.tsx new file mode 100644 index 0000000..b6b7f4c --- /dev/null +++ b/webview/__tests__/components/molecules/FileCreateView.test.tsx @@ -0,0 +1,33 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FileCreateView } from "../../../components/molecules/FileCreateView"; + +describe("FileCreateView", () => { + // when content has a few lines + context("少数行のコンテンツの場合", () => { + // renders all lines as add lines + it("すべての行を追加行として表示すること", () => { + const content = "line1\nline2\nline3"; + const { container } = render(); + expect(container.querySelectorAll(".tool-diff-line-add")).toHaveLength(3); + }); + + // shows add lines count in stats + it("追加行数を stats に表示すること", () => { + const content = "line1\nline2"; + const { container } = render(); + expect(container.querySelector(".tool-diff-stat-add")).toBeInTheDocument(); + }); + }); + + // when content exceeds 30 lines + context("コンテンツが 30 行を超える場合", () => { + // truncates to 31 display lines (30 + more indicator) + it("表示行を 31 行に切り詰めること", () => { + const lines = Array.from({ length: 50 }, (_, i) => `line${i + 1}`); + const content = lines.join("\n"); + const { container } = render(); + expect(container.querySelectorAll(".tool-diff-line-add")).toHaveLength(31); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/HashFilePopup.test.tsx b/webview/__tests__/components/molecules/HashFilePopup.test.tsx new file mode 100644 index 0000000..1a9279b --- /dev/null +++ b/webview/__tests__/components/molecules/HashFilePopup.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { HashFilePopup } from "../../../components/molecules/HashFilePopup"; +import type { FileAttachment } from "../../../vscode-api"; + +const file1: FileAttachment = { fileName: "app.tsx", filePath: "/src/app.tsx" }; +const file2: FileAttachment = { fileName: "index.ts", filePath: "/src/index.ts" }; + +function createRef() { + return { current: null } as React.RefObject; +} + +describe("HashFilePopup", () => { + // when files are available + context("ファイル候補がある場合", () => { + // renders file items + it("ファイルアイテムをレンダリングすること", () => { + const { container } = render( + , + ); + expect(container.querySelectorAll(".hash-popup-item")).toHaveLength(2); + }); + + // calls onAddFile when item is clicked + it("アイテムクリックで onAddFile が呼ばれること", () => { + const onAddFile = vi.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector(".hash-popup-item")!); + expect(onAddFile).toHaveBeenCalledWith(file1); + }); + }); + + // when no files match + context("ファイル候補がない場合", () => { + // renders empty message + it("空メッセージを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".hash-popup-empty")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/ModelSelector.test.tsx b/webview/__tests__/components/molecules/ModelSelector.test.tsx new file mode 100644 index 0000000..a25364f --- /dev/null +++ b/webview/__tests__/components/molecules/ModelSelector.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ModelSelector } from "../../../components/molecules/ModelSelector"; +import { createProvider } from "../../factories"; + +const defaultProps = { + providers: [ + createProvider("openai", { + "gpt-4": { id: "gpt-4", name: "GPT-4", limit: { context: 128000, output: 4096 } }, + }), + ], + allProvidersData: null, + selectedModel: { providerID: "openai", modelID: "gpt-4" }, + onSelect: vi.fn(), +}; + +describe("ModelSelector", () => { + // when rendered + context("レンダリングした場合", () => { + // renders the selector button + it("セレクターボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".model-selector-button")).toBeInTheDocument(); + }); + + // shows selected model name + it("選択中のモデル名を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".model-selector-label")?.textContent).toBe("GPT-4"); + }); + }); + + // when button is clicked + context("ボタンをクリックした場合", () => { + // opens the model panel + it("モデルパネルを開くこと", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".model-selector-button")!); + expect(container.querySelector(".model-panel")).toBeInTheDocument(); + }); + }); + + // when a model is selected + context("モデルを選択した場合", () => { + // calls onSelect with the model + it("onSelect が呼ばれること", () => { + const onSelect = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector(".model-selector-button")!); + fireEvent.click(container.querySelector(".model-panel-item")!); + expect(onSelect).toHaveBeenCalledWith({ providerID: "openai", modelID: "gpt-4" }); + }); + }); + + // when no model is selected + context("モデルが未選択の場合", () => { + // shows placeholder text + it("プレースホルダーテキストを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".model-selector-label")?.textContent).toBeTruthy(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/TextPartView.test.tsx b/webview/__tests__/components/molecules/TextPartView.test.tsx new file mode 100644 index 0000000..cc091a3 --- /dev/null +++ b/webview/__tests__/components/molecules/TextPartView.test.tsx @@ -0,0 +1,33 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TextPartView } from "../../../components/molecules/TextPartView"; +import { createTextPart } from "../../factories"; + +describe("TextPartView", () => { + // when rendered with a text part + context("テキストパートを渡した場合", () => { + // renders HTML content + it("HTML コンテンツをレンダリングすること", () => { + const part = createTextPart("Hello world"); + const { container } = render(); + expect(container.querySelector("span")).toBeInTheDocument(); + }); + + // renders the text + it("テキストを表示すること", () => { + const part = createTextPart("Hello world"); + const { container } = render(); + expect(container.textContent).toContain("Hello world"); + }); + }); + + // when text changes + context("テキストが異なる場合", () => { + // renders updated text + it("更新されたテキストを表示すること", () => { + const part = createTextPart("Updated text"); + const { container } = render(); + expect(container.textContent).toContain("Updated text"); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/TodoHeader.test.tsx b/webview/__tests__/components/molecules/TodoHeader.test.tsx new file mode 100644 index 0000000..3b9955e --- /dev/null +++ b/webview/__tests__/components/molecules/TodoHeader.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TodoHeader } from "../../../components/molecules/TodoHeader"; +import type { TodoItem } from "../../../utils/todo"; + +const sampleTodos: TodoItem[] = [ + { content: "Task 1", status: "completed", priority: undefined }, + { content: "Task 2", status: "in_progress", priority: "high" }, + { content: "Task 3", status: "pending", priority: undefined }, +]; + +describe("TodoHeader", () => { + // when rendered + context("レンダリングした場合", () => { + // renders the header bar + it("ヘッダーバーをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".todo-header-bar")).toBeInTheDocument(); + }); + + // shows completion count + it("完了数を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".todo-header-count")?.textContent).toBe("1/3"); + }); + + // renders progress bar + it("プログレスバーをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".todo-header-progress")).toBeInTheDocument(); + }); + }); + + // when header is clicked + context("ヘッダーをクリックした場合", () => { + // expands the todo list + it("todo リストを展開すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".todo-header-bar")!); + expect(container.querySelector(".todo-header-list")).toBeInTheDocument(); + }); + + // shows all todo items + it("全ての todo アイテムを表示すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".todo-header-bar")!); + expect(container.querySelectorAll(".todo-header-item")).toHaveLength(3); + }); + }); + + // when all todos are completed + context("全 todo が完了している場合", () => { + // shows full completion count + it("全完了数を表示すること", () => { + const allDone: TodoItem[] = [ + { content: "Done 1", status: "completed", priority: undefined }, + { content: "Done 2", status: "done", priority: undefined }, + ]; + const { container } = render(); + expect(container.querySelector(".todo-header-count")?.textContent).toBe("2/2"); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/TodoView.test.tsx b/webview/__tests__/components/molecules/TodoView.test.tsx new file mode 100644 index 0000000..76a6965 --- /dev/null +++ b/webview/__tests__/components/molecules/TodoView.test.tsx @@ -0,0 +1,59 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TodoView } from "../../../components/molecules/TodoView"; +import type { TodoItem } from "../../../utils/todo"; + +const sampleTodos: TodoItem[] = [ + { content: "Task 1", status: "completed", priority: undefined }, + { content: "Task 2", status: "in_progress", priority: "high" }, + { content: "Task 3", status: "pending", priority: "low" }, +]; + +describe("TodoView", () => { + // when todos are provided + context("todo リストがある場合", () => { + // renders all todo items + it("全ての todo アイテムをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".tool-todo-item")).toHaveLength(3); + }); + + // shows completion summary + it("完了数サマリーを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".tool-todo-summary")).toBeInTheDocument(); + }); + + // marks completed items with check mark + it("完了アイテムにチェックマークを表示すること", () => { + const { container } = render(); + const checks = container.querySelectorAll(".tool-todo-check"); + expect(checks[0]?.textContent).toBe("✓"); + }); + + // marks incomplete items with circle + it("未完了アイテムに丸を表示すること", () => { + const { container } = render(); + const checks = container.querySelectorAll(".tool-todo-check"); + expect(checks[1]?.textContent).toBe("○"); + }); + }); + + // when a todo has priority + context("todo に priority がある場合", () => { + // renders priority badge + it("priority バッジを表示すること", () => { + const { container } = render(); + expect(container.querySelectorAll(".tool-todo-priority").length).toBeGreaterThan(0); + }); + }); + + // when todos list is empty + context("todo リストが空の場合", () => { + // renders no items + it("アイテムをレンダリングしないこと", () => { + const { container } = render(); + expect(container.querySelectorAll(".tool-todo-item")).toHaveLength(0); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/ChatHeader.test.tsx b/webview/__tests__/components/organisms/ChatHeader.test.tsx new file mode 100644 index 0000000..415c568 --- /dev/null +++ b/webview/__tests__/components/organisms/ChatHeader.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ChatHeader } from "../../../components/organisms/ChatHeader"; +import { createSession } from "../../factories"; + +describe("ChatHeader", () => { + const defaultProps = { + activeSession: createSession({ title: "Test Session" }), + onNewSession: vi.fn(), + onToggleSessionList: vi.fn(), + }; + + // when rendered with an active session + context("アクティブセッションがある場合", () => { + // renders the session title + it("セッションタイトルを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".chat-header-title")?.textContent).toBe("Test Session"); + }); + + // renders the session list button + it("セッションリストボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".btn-icon").length).toBeGreaterThan(0); + }); + }); + + // when new session button is clicked + context("新規セッションボタンをクリックした場合", () => { + // calls onNewSession + it("onNewSession が呼ばれること", () => { + const onNewSession = vi.fn(); + const { container } = render(); + const buttons = container.querySelectorAll(".btn-icon"); + // New chat button is the second btn-icon + fireEvent.click(buttons[buttons.length - 1]!); + expect(onNewSession).toHaveBeenCalledOnce(); + }); + }); + + // when session list button is clicked + context("セッションリストボタンをクリックした場合", () => { + // calls onToggleSessionList + it("onToggleSessionList が呼ばれること", () => { + const onToggleSessionList = vi.fn(); + const { container } = render(); + const buttons = container.querySelectorAll(".btn-icon"); + fireEvent.click(buttons[0]!); + expect(onToggleSessionList).toHaveBeenCalledOnce(); + }); + }); + + // when activeSession is null + context("アクティブセッションが null の場合", () => { + // renders fallback title + it("フォールバックタイトルを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".chat-header-title")?.textContent).toBeTruthy(); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/MessageItem.test.tsx b/webview/__tests__/components/organisms/MessageItem.test.tsx new file mode 100644 index 0000000..b1ce1f8 --- /dev/null +++ b/webview/__tests__/components/organisms/MessageItem.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MessageItem } from "../../../components/organisms/MessageItem"; +import { createMessage, createTextPart, createToolPart } from "../../factories"; +import type { MessageWithParts } from "../../../App"; + +describe("MessageItem", () => { + const defaultProps = { + activeSessionId: "session-1", + permissions: new Map(), + onEditAndResend: vi.fn(), + }; + + // when rendered with a user message + context("ユーザーメッセージの場合", () => { + const userMsg: MessageWithParts = { + info: createMessage({ role: "user" }), + parts: [createTextPart("Hello")], + }; + + // renders as user message + it("ユーザーメッセージとしてレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".message-user")).toBeInTheDocument(); + }); + + // renders user text + it("ユーザーテキストを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".message-content")?.textContent).toBe("Hello"); + }); + + // shows edit icon + it("編集アイコンを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".message-edit-icon")).toBeInTheDocument(); + }); + }); + + // when user message bubble is clicked + context("ユーザーメッセージバブルをクリックした場合", () => { + const userMsg: MessageWithParts = { + info: createMessage({ role: "user" }), + parts: [createTextPart("Hello")], + }; + + // enters edit mode + it("編集モードに入ること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".message-user-bubble")!); + expect(container.querySelector(".message-edit-textarea")).toBeInTheDocument(); + }); + }); + + // when rendered with an assistant message + context("アシスタントメッセージの場合", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response"), createToolPart("file_read")], + }; + + // renders as assistant message + it("アシスタントメッセージとしてレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".message-assistant")).toBeInTheDocument(); + }); + + // renders text and tool parts + it("テキストとツールパートをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".tool-part")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/MessagesArea.test.tsx b/webview/__tests__/components/organisms/MessagesArea.test.tsx new file mode 100644 index 0000000..1a0e512 --- /dev/null +++ b/webview/__tests__/components/organisms/MessagesArea.test.tsx @@ -0,0 +1,66 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MessagesArea } from "../../../components/organisms/MessagesArea"; +import { createMessage, createTextPart } from "../../factories"; +import type { MessageWithParts } from "../../../App"; + +const userMsg: MessageWithParts = { + info: createMessage({ role: "user" }), + parts: [createTextPart("Hello")], +}; + +const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Hi there")], +}; + +const defaultProps = { + messages: [userMsg, assistantMsg], + sessionBusy: false, + activeSessionId: "session-1", + permissions: new Map(), + onEditAndResend: vi.fn(), + onRevertToCheckpoint: vi.fn(), +}; + +describe("MessagesArea", () => { + // when rendered with messages + context("メッセージがある場合", () => { + // renders message items + it("メッセージアイテムをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".message").length).toBeGreaterThan(0); + }); + }); + + // when session is busy + context("セッションが busy の場合", () => { + // renders streaming indicator + it("StreamingIndicator をレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".streaming-indicator")).toBeInTheDocument(); + }); + }); + + // when session is not busy + context("セッションが busy でない場合", () => { + // does not render streaming indicator + it("StreamingIndicator をレンダリングしないこと", () => { + const { container } = render(); + expect(container.querySelector(".streaming-indicator")).not.toBeInTheDocument(); + }); + }); + + // when messages have checkpoint dividers + context("アシスタント→ユーザーの連続メッセージの場合", () => { + // renders checkpoint divider + it("チェックポイント区切り線をレンダリングすること", () => { + const msgs: MessageWithParts[] = [ + { info: createMessage({ role: "assistant" }), parts: [createTextPart("Reply")] }, + { info: createMessage({ role: "user" }), parts: [createTextPart("Follow up")] }, + ]; + const { container } = render(); + expect(container.querySelector(".checkpoint-divider")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/PermissionView.test.tsx b/webview/__tests__/components/organisms/PermissionView.test.tsx new file mode 100644 index 0000000..c9abf47 --- /dev/null +++ b/webview/__tests__/components/organisms/PermissionView.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PermissionView } from "../../../components/organisms/PermissionView"; +import { createPermission } from "../../factories"; +import { postMessage } from "../../../vscode-api"; + +describe("PermissionView", () => { + const defaultProps = { + permission: createPermission({ title: "Allow file write?" }), + activeSessionId: "session-1", + }; + + // when rendered + context("レンダリングした場合", () => { + // renders the permission title + it("パーミッションタイトルを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".permission-title")?.textContent).toBe("Allow file write?"); + }); + + // renders three action buttons + it("3 つのアクションボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".permission-actions .btn")).toHaveLength(3); + }); + }); + + // when Allow button is clicked + context("Allow ボタンをクリックした場合", () => { + // sends always response + it("always レスポンスを送信すること", () => { + const { container } = render(); + const buttons = container.querySelectorAll(".permission-actions .btn"); + fireEvent.click(buttons[0]!); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "replyPermission", response: "always" }), + ); + }); + }); + + // when Once button is clicked + context("Once ボタンをクリックした場合", () => { + // sends once response + it("once レスポンスを送信すること", () => { + const { container } = render(); + const buttons = container.querySelectorAll(".permission-actions .btn"); + fireEvent.click(buttons[1]!); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "replyPermission", response: "once" }), + ); + }); + }); + + // when Deny button is clicked + context("Deny ボタンをクリックした場合", () => { + // sends reject response + it("reject レスポンスを送信すること", () => { + const { container } = render(); + const buttons = container.querySelectorAll(".permission-actions .btn"); + fireEvent.click(buttons[2]!); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "replyPermission", response: "reject" }), + ); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/SessionList.test.tsx b/webview/__tests__/components/organisms/SessionList.test.tsx new file mode 100644 index 0000000..f47ea45 --- /dev/null +++ b/webview/__tests__/components/organisms/SessionList.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SessionList } from "../../../components/organisms/SessionList"; +import { createSession } from "../../factories"; + +const sessions = [ + createSession({ id: "s1", title: "Session 1" }), + createSession({ id: "s2", title: "Session 2" }), +]; + +const defaultProps = { + sessions, + activeSessionId: "s1", + onSelect: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), +}; + +describe("SessionList", () => { + // when rendered with sessions + context("セッションがある場合", () => { + // renders all session items + it("全セッションアイテムをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".session-item")).toHaveLength(2); + }); + + // highlights the active session + it("アクティブセッションをハイライトすること", () => { + const { container } = render(); + expect(container.querySelector(".session-item.active")).toBeInTheDocument(); + }); + }); + + // when a session is clicked + context("セッションをクリックした場合", () => { + // calls onSelect + it("onSelect が呼ばれること", () => { + const onSelect = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelectorAll(".session-item")[1]!); + expect(onSelect).toHaveBeenCalledWith("s2"); + }); + }); + + // when delete button is clicked + context("削除ボタンをクリックした場合", () => { + // calls onDelete + it("onDelete が呼ばれること", () => { + const onDelete = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector(".session-item-delete")!); + expect(onDelete).toHaveBeenCalledWith("s1"); + }); + }); + + // when there are no sessions + context("セッションがない場合", () => { + // renders empty message + it("空メッセージを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".session-item")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/ToolConfigPanel.test.tsx b/webview/__tests__/components/organisms/ToolConfigPanel.test.tsx new file mode 100644 index 0000000..f2c46be --- /dev/null +++ b/webview/__tests__/components/organisms/ToolConfigPanel.test.tsx @@ -0,0 +1,52 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ToolConfigPanel } from "../../../components/organisms/ToolConfigPanel"; + +const defaultProps = { + paths: { home: "/home", config: "/config", state: "/state", directory: "/project" }, + onOpenConfigFile: vi.fn(), + onClose: vi.fn(), + localeSetting: "auto" as const, + onLocaleSettingChange: vi.fn(), +}; + +describe("ToolConfigPanel", () => { + // when rendered + context("レンダリングした場合", () => { + // renders the panel + it("パネルをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".tool-config-panel")).toBeInTheDocument(); + }); + + // renders close button + it("閉じるボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".tool-config-close")).toBeInTheDocument(); + }); + + // renders language options + it("言語オプションをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll("input[name='locale']")).toHaveLength(3); + }); + }); + + // when paths are provided + context("paths が提供されている場合", () => { + // renders config file links + it("設定ファイルリンクをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".tool-config-link")).toHaveLength(2); + }); + }); + + // when paths are null + context("paths が null の場合", () => { + // does not render config file links + it("設定ファイルリンクをレンダリングしないこと", () => { + const { container } = render(); + expect(container.querySelector(".tool-config-link")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/ToolPartView.test.tsx b/webview/__tests__/components/organisms/ToolPartView.test.tsx new file mode 100644 index 0000000..ffe55ce --- /dev/null +++ b/webview/__tests__/components/organisms/ToolPartView.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ToolPartView } from "../../../components/organisms/ToolPartView"; +import { createToolPart } from "../../factories"; + +describe("ToolPartView", () => { + // when rendered with a completed tool part + context("completed 状態のツールパートの場合", () => { + // renders the tool part header + it("ツールパートヘッダーをレンダリングすること", () => { + const part = createToolPart("file_read"); + const { container } = render(); + expect(container.querySelector(".tool-part-header")).toBeInTheDocument(); + }); + + // renders the action icon + it("アクションアイコンをレンダリングすること", () => { + const part = createToolPart("file_read"); + const { container } = render(); + expect(container.querySelector(".tool-part-icon")).toBeInTheDocument(); + }); + + // renders the action label + it("アクションラベルをレンダリングすること", () => { + const part = createToolPart("file_read"); + const { container } = render(); + expect(container.querySelector(".tool-part-action")).toBeInTheDocument(); + }); + }); + + // when header is clicked + context("ヘッダーをクリックした場合", () => { + // shows the body + it("ボディを表示すること", () => { + const part = createToolPart("file_read", { + state: { status: "completed", title: "test", input: {}, output: "result" }, + } as any); + const { container } = render(); + fireEvent.click(container.querySelector(".tool-part-header")!); + expect(container.querySelector(".tool-part-body")).toBeInTheDocument(); + }); + }); + + // when tool is in running state + context("running 状態の場合", () => { + // renders the spinner icon + it("スピナーアイコンをレンダリングすること", () => { + const part = createToolPart("bash", { + state: { status: "running", title: "running...", input: {} }, + } as any); + const { container } = render(); + expect(container.querySelector(".tool-part-spinner")).toBeInTheDocument(); + }); + }); + + // when tool is in error state + context("error 状態の場合", () => { + // renders error icon + it("エラーアイコンをレンダリングすること", () => { + const part = createToolPart("bash", { + state: { status: "error", title: "failed", input: {}, error: "something went wrong" }, + } as any); + const { container } = render(); + expect(container.querySelector(".tool-part-icon svg")).toBeInTheDocument(); + }); + }); + + // when expanded and has file edit input + context("展開中でファイル編集入力がある場合", () => { + // renders DiffView + it("DiffView をレンダリングすること", () => { + const part = createToolPart("file_edit", { + state: { + status: "completed", + title: "test.ts", + input: { oldString: "old", newString: "new", filePath: "test.ts" }, + output: "ok", + }, + } as any); + const { container } = render(); + fireEvent.click(container.querySelector(".tool-part-header")!); + expect(container.querySelector(".tool-diff")).toBeInTheDocument(); + }); + }); + + // when expanded and has file create input + context("展開中でファイル作成入力がある場合", () => { + // renders FileCreateView + it("FileCreateView をレンダリングすること", () => { + const part = createToolPart("file_write", { + state: { + status: "completed", + title: "new.ts", + input: { content: "new content", filePath: "new.ts" }, + output: "ok", + }, + } as any); + const { container } = render(); + fireEvent.click(container.querySelector(".tool-part-header")!); + expect(container.querySelector(".tool-diff")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/components/ContextIndicator.tsx b/webview/components/atoms/ContextIndicator.tsx similarity index 97% rename from webview/components/ContextIndicator.tsx rename to webview/components/atoms/ContextIndicator.tsx index 5d2d5b9..9ae9804 100644 --- a/webview/components/ContextIndicator.tsx +++ b/webview/components/atoms/ContextIndicator.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocale } from "../locales"; +import { useLocale } from "../../locales"; type Props = { inputTokens: number; @@ -8,7 +8,7 @@ type Props = { isCompressing: boolean; }; -function formatTokenCount(n: number): string { +export function formatTokenCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); diff --git a/webview/components/StreamingIndicator.tsx b/webview/components/atoms/StreamingIndicator.tsx similarity index 100% rename from webview/components/StreamingIndicator.tsx rename to webview/components/atoms/StreamingIndicator.tsx diff --git a/webview/components/molecules/DiffView.tsx b/webview/components/molecules/DiffView.tsx new file mode 100644 index 0000000..5b29467 --- /dev/null +++ b/webview/components/molecules/DiffView.tsx @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import { computeLineDiff } from "../../utils/diff"; + +type Props = { + oldStr: string; + newStr: string; +}; + +export function DiffView({ oldStr, newStr }: Props) { + const lines = useMemo(() => computeLineDiff(oldStr, newStr), [oldStr, newStr]); + const addCount = lines.filter((l) => l.type === "add").length; + const removeCount = lines.filter((l) => l.type === "remove").length; + + return ( +
    +
    + {addCount > 0 && +{addCount}} + {removeCount > 0 && −{removeCount}} +
    +
    + {lines.map((line, i) => ( +
    + + {line.type === "add" ? "+" : line.type === "remove" ? "−" : " "} + + {line.text || "\u00A0"} +
    + ))} +
    +
    + ); +} diff --git a/webview/components/EmptyState.tsx b/webview/components/molecules/EmptyState.tsx similarity index 91% rename from webview/components/EmptyState.tsx rename to webview/components/molecules/EmptyState.tsx index 3c00141..1978f04 100644 --- a/webview/components/EmptyState.tsx +++ b/webview/components/molecules/EmptyState.tsx @@ -1,4 +1,4 @@ -import { useLocale } from "../locales"; +import { useLocale } from "../../locales"; type Props = { onNewSession: () => void; diff --git a/webview/components/molecules/FileAttachmentBar.tsx b/webview/components/molecules/FileAttachmentBar.tsx new file mode 100644 index 0000000..9affb48 --- /dev/null +++ b/webview/components/molecules/FileAttachmentBar.tsx @@ -0,0 +1,97 @@ +import { useLocale } from "../../locales"; +import type { FileAttachment } from "../../vscode-api"; +import { ClipIcon, CloseIcon, PlusIcon } from "../atoms/icons"; + +type Props = { + attachedFiles: FileAttachment[]; + activeEditorFile: FileAttachment | null; + isActiveAttached: boolean; + showFilePicker: boolean; + filePickerQuery: string; + pickerFiles: FileAttachment[]; + onClipClick: () => void; + onFilePickerSearch: (query: string) => void; + onAddFile: (file: FileAttachment) => void; + onRemoveFile: (filePath: string) => void; + filePickerRef: React.RefObject; +}; + +export function FileAttachmentBar({ + attachedFiles, + activeEditorFile, + isActiveAttached, + showFilePicker, + filePickerQuery, + pickerFiles, + onClipClick, + onFilePickerSearch, + onAddFile, + onRemoveFile, + filePickerRef, +}: Props) { + const t = useLocale(); + + return ( +
    + {/* クリップボタン */} +
    + + {showFilePicker && ( +
    + onFilePickerSearch(e.target.value)} + /> +
    + {pickerFiles.length > 0 ? ( + pickerFiles.slice(0, 15).map((file) => ( +
    onAddFile(file)}> + {file.fileName} + {file.filePath} +
    + )) + ) : ( +
    {t["input.noFiles"]}
    + )} +
    +
    + )} +
    + {/* 添付されたファイルチップ (インライン) */} + {attachedFiles.map((file) => ( +
    + {file.fileName} + +
    + ))} + {/* 現在開いているファイルの quick-add ボタン */} + {activeEditorFile && !isActiveAttached && ( + + )} +
    + ); +} diff --git a/webview/components/molecules/FileCreateView.tsx b/webview/components/molecules/FileCreateView.tsx new file mode 100644 index 0000000..dbc319e --- /dev/null +++ b/webview/components/molecules/FileCreateView.tsx @@ -0,0 +1,26 @@ +import { useLocale } from "../../locales"; + +type Props = { + content: string; +}; + +export function FileCreateView({ content }: Props) { + const t = useLocale(); + const lines = content.split("\n"); + const displayLines = lines.length > 30 ? [...lines.slice(0, 30), t["tool.moreLines"](lines.length - 30)] : lines; + return ( +
    +
    + {t["tool.addLines"](lines.length)} +
    +
    + {displayLines.map((line, i) => ( +
    + + + {line || "\u00A0"} +
    + ))} +
    +
    + ); +} diff --git a/webview/components/molecules/HashFilePopup.tsx b/webview/components/molecules/HashFilePopup.tsx new file mode 100644 index 0000000..4fc54e3 --- /dev/null +++ b/webview/components/molecules/HashFilePopup.tsx @@ -0,0 +1,27 @@ +import { useLocale } from "../../locales"; +import type { FileAttachment } from "../../vscode-api"; + +type Props = { + hashFiles: FileAttachment[]; + onAddFile: (file: FileAttachment) => void; + hashPopupRef: React.RefObject; +}; + +export function HashFilePopup({ hashFiles, onAddFile, hashPopupRef }: Props) { + const t = useLocale(); + + return ( +
    + {hashFiles.length > 0 ? ( + hashFiles.map((file) => ( +
    onAddFile(file)}> + {file.fileName} + {file.filePath} +
    + )) + ) : ( +
    {t["input.noFiles"]}
    + )} +
    + ); +} diff --git a/webview/components/ModelSelector.tsx b/webview/components/molecules/ModelSelector.tsx similarity index 98% rename from webview/components/ModelSelector.tsx rename to webview/components/molecules/ModelSelector.tsx index f3f05ab..f2db02a 100644 --- a/webview/components/ModelSelector.tsx +++ b/webview/components/molecules/ModelSelector.tsx @@ -1,8 +1,8 @@ import type { Provider } from "@opencode-ai/sdk"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocale } from "../locales"; -import type { AllProvidersData, ModelInfo, ProviderInfo } from "../vscode-api"; -import { ChevronRightIcon, EyeIcon, EyeOffIcon } from "./atoms/icons"; +import { useLocale } from "../../locales"; +import type { AllProvidersData, ModelInfo, ProviderInfo } from "../../vscode-api"; +import { ChevronRightIcon, EyeIcon, EyeOffIcon } from "../atoms/icons"; type Props = { providers: Provider[]; diff --git a/webview/components/TextPartView.tsx b/webview/components/molecules/TextPartView.tsx similarity index 100% rename from webview/components/TextPartView.tsx rename to webview/components/molecules/TextPartView.tsx diff --git a/webview/components/TodoHeader.tsx b/webview/components/molecules/TodoHeader.tsx similarity index 92% rename from webview/components/TodoHeader.tsx rename to webview/components/molecules/TodoHeader.tsx index a677e34..01f4ade 100644 --- a/webview/components/TodoHeader.tsx +++ b/webview/components/molecules/TodoHeader.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { useLocale } from "../locales"; -import type { TodoItem } from "../utils/todo"; -import { CheckboxIcon, ChevronRightIcon } from "./atoms/icons"; +import { useLocale } from "../../locales"; +import type { TodoItem } from "../../utils/todo"; +import { CheckboxIcon, ChevronRightIcon } from "../atoms/icons"; type Props = { todos: TodoItem[]; diff --git a/webview/components/molecules/TodoView.tsx b/webview/components/molecules/TodoView.tsx new file mode 100644 index 0000000..f8a68a2 --- /dev/null +++ b/webview/components/molecules/TodoView.tsx @@ -0,0 +1,31 @@ +import { useLocale } from "../../locales"; +import type { TodoItem } from "../../utils/todo"; + +type Props = { + todos: TodoItem[]; +}; + +export function TodoView({ todos }: Props) { + const t = useLocale(); + const completed = todos.filter((td) => td.status === "completed" || td.status === "done").length; + const total = todos.length; + + return ( +
    +
    {t["tool.completed"](completed, total)}
    +
      + {todos.map((todo, i) => { + const isDone = todo.status === "completed" || todo.status === "done"; + const priorityClass = todo.priority === "high" ? "high" : todo.priority === "low" ? "low" : ""; + return ( +
    • + {isDone ? "✓" : "○"} + {todo.content} + {todo.priority && {todo.priority}} +
    • + ); + })} +
    +
    + ); +} diff --git a/webview/components/ChatHeader.tsx b/webview/components/organisms/ChatHeader.tsx similarity index 89% rename from webview/components/ChatHeader.tsx rename to webview/components/organisms/ChatHeader.tsx index 6eb5336..a5e5946 100644 --- a/webview/components/ChatHeader.tsx +++ b/webview/components/organisms/ChatHeader.tsx @@ -1,6 +1,6 @@ import type { Session } from "@opencode-ai/sdk"; -import { useLocale } from "../locales"; -import { AddIcon, ListIcon } from "./atoms/icons"; +import { useLocale } from "../../locales"; +import { AddIcon, ListIcon } from "../atoms/icons"; type Props = { activeSession: Session | null; diff --git a/webview/components/InputArea.tsx b/webview/components/organisms/InputArea.tsx similarity index 77% rename from webview/components/InputArea.tsx rename to webview/components/organisms/InputArea.tsx index d8019e7..7c3a362 100644 --- a/webview/components/InputArea.tsx +++ b/webview/components/organisms/InputArea.tsx @@ -1,22 +1,21 @@ import type { Provider } from "@opencode-ai/sdk"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; -import type { LocaleSetting } from "../locales"; -import { useLocale } from "../locales"; -import type { AllProvidersData, FileAttachment } from "../vscode-api"; -import { postMessage } from "../vscode-api"; +import type { LocaleSetting } from "../../locales"; +import { useLocale } from "../../locales"; +import type { AllProvidersData, FileAttachment } from "../../vscode-api"; +import { postMessage } from "../../vscode-api"; import { ChevronRightIcon, - ClipIcon, - CloseIcon, GearIcon, - PlusIcon, SendIcon, StopIcon, TerminalIcon, -} from "./atoms/icons"; -import { ContextIndicator } from "./ContextIndicator"; -import { ModelSelector } from "./ModelSelector"; -import { ToolConfigPanel } from "./ToolConfigPanel"; +} from "../atoms/icons"; +import { ContextIndicator } from "../atoms/ContextIndicator"; +import { FileAttachmentBar } from "../molecules/FileAttachmentBar"; +import { HashFilePopup } from "../molecules/HashFilePopup"; +import { ModelSelector } from "../molecules/ModelSelector"; +import { ToolConfigPanel } from "../organisms/ToolConfigPanel"; type Props = { onSend: (text: string, files: FileAttachment[]) => void; @@ -286,67 +285,19 @@ export function InputArea({
    {/* コンテキストバー: クリップボタン + 添付ファイルチップ + quick-add を1行に */}
    -
    - {/* クリップボタン */} -
    - - {showFilePicker && ( -
    - handleFilePickerSearch(e.target.value)} - /> -
    - {pickerFiles.length > 0 ? ( - pickerFiles.slice(0, 15).map((file) => ( -
    addFile(file)}> - {file.fileName} - {file.filePath} -
    - )) - ) : ( -
    {t["input.noFiles"]}
    - )} -
    -
    - )} -
    - {/* 添付されたファイルチップ (インライン) */} - {attachedFiles.map((file) => ( -
    - {file.fileName} - -
    - ))} - {/* 現在開いているファイルの quick-add ボタン */} - {activeEditorFile && !isActiveAttached && ( - - )} -
    + {/* コンテキストウィンドウ使用率インジケーター (右側) */} {contextLimit > 0 && ( {/* # トリガー ファイル候補ポップアップ */} {hashTrigger.active && ( -
    - {hashFiles.length > 0 ? ( - hashFiles.map((file) => ( -
    addFile(file)}> - {file.fileName} - {file.filePath} -
    - )) - ) : ( -
    {t["input.noFiles"]}
    - )} -
    + )}
    diff --git a/webview/components/MessageItem.tsx b/webview/components/organisms/MessageItem.tsx similarity index 97% rename from webview/components/MessageItem.tsx rename to webview/components/organisms/MessageItem.tsx index df54ba6..133620e 100644 --- a/webview/components/MessageItem.tsx +++ b/webview/components/organisms/MessageItem.tsx @@ -1,10 +1,10 @@ import type { Permission, ReasoningPart as ReasoningPartType, TextPart } from "@opencode-ai/sdk"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { MessageWithParts } from "../App"; -import { useLocale } from "../locales"; -import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "./atoms/icons"; +import type { MessageWithParts } from "../../App"; +import { useLocale } from "../../locales"; +import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "../atoms/icons"; import { PermissionView } from "./PermissionView"; -import { TextPartView } from "./TextPartView"; +import { TextPartView } from "../molecules/TextPartView"; import { ToolPartView } from "./ToolPartView"; type Props = { diff --git a/webview/components/MessagesArea.tsx b/webview/components/organisms/MessagesArea.tsx similarity index 93% rename from webview/components/MessagesArea.tsx rename to webview/components/organisms/MessagesArea.tsx index c9bdad0..b23ec01 100644 --- a/webview/components/MessagesArea.tsx +++ b/webview/components/organisms/MessagesArea.tsx @@ -1,10 +1,10 @@ import type { Permission } from "@opencode-ai/sdk"; import { useEffect, useRef } from "react"; -import type { MessageWithParts } from "../App"; -import { useLocale } from "../locales"; -import { RevertIcon } from "./atoms/icons"; +import type { MessageWithParts } from "../../App"; +import { useLocale } from "../../locales"; +import { RevertIcon } from "../atoms/icons"; import { MessageItem } from "./MessageItem"; -import { StreamingIndicator } from "./StreamingIndicator"; +import { StreamingIndicator } from "../atoms/StreamingIndicator"; type Props = { messages: MessageWithParts[]; diff --git a/webview/components/PermissionView.tsx b/webview/components/organisms/PermissionView.tsx similarity index 91% rename from webview/components/PermissionView.tsx rename to webview/components/organisms/PermissionView.tsx index 4278d52..53b0905 100644 --- a/webview/components/PermissionView.tsx +++ b/webview/components/organisms/PermissionView.tsx @@ -1,6 +1,6 @@ import type { Permission } from "@opencode-ai/sdk"; -import { useLocale } from "../locales"; -import { postMessage } from "../vscode-api"; +import { useLocale } from "../../locales"; +import { postMessage } from "../../vscode-api"; type Props = { permission: Permission; diff --git a/webview/components/SessionList.tsx b/webview/components/organisms/SessionList.tsx similarity index 96% rename from webview/components/SessionList.tsx rename to webview/components/organisms/SessionList.tsx index 8b86c69..4460faa 100644 --- a/webview/components/SessionList.tsx +++ b/webview/components/organisms/SessionList.tsx @@ -1,6 +1,6 @@ import type { Session } from "@opencode-ai/sdk"; -import { useLocale } from "../locales"; -import { DeleteIcon, FileIcon } from "./atoms/icons"; +import { useLocale } from "../../locales"; +import { DeleteIcon, FileIcon } from "../atoms/icons"; type Props = { sessions: Session[]; diff --git a/webview/components/ToolConfigPanel.tsx b/webview/components/organisms/ToolConfigPanel.tsx similarity index 94% rename from webview/components/ToolConfigPanel.tsx rename to webview/components/organisms/ToolConfigPanel.tsx index 8ba51f4..acb6df9 100644 --- a/webview/components/ToolConfigPanel.tsx +++ b/webview/components/organisms/ToolConfigPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; -import type { LocaleSetting } from "../locales"; -import { useLocale } from "../locales"; -import { CloseIcon, FileIcon } from "./atoms/icons"; +import type { LocaleSetting } from "../../locales"; +import { useLocale } from "../../locales"; +import { CloseIcon, FileIcon } from "../atoms/icons"; type Props = { paths: { home: string; config: string; state: string; directory: string } | null; diff --git a/webview/components/ToolPartView.tsx b/webview/components/organisms/ToolPartView.tsx similarity index 57% rename from webview/components/ToolPartView.tsx rename to webview/components/organisms/ToolPartView.tsx index 8c731e8..bdd7a31 100644 --- a/webview/components/ToolPartView.tsx +++ b/webview/components/organisms/ToolPartView.tsx @@ -1,10 +1,8 @@ import type { ToolPart } from "@opencode-ai/sdk"; import { useMemo, useState } from "react"; -import { useLocale } from "../locales"; -import type { TodoItem } from "../utils/todo"; -import { parseTodos } from "../utils/todo"; -import { CATEGORY_LABEL_KEYS, getCategory } from "../utils/tool-categories"; -import { computeLineDiff } from "../utils/diff"; +import { useLocale } from "../../locales"; +import { parseTodos } from "../../utils/todo"; +import { CATEGORY_LABEL_KEYS, type ToolCategory, getCategory } from "../../utils/tool-categories"; import { ChevronRightIcon, EditActionIcon, @@ -15,7 +13,10 @@ import { SpinnerIcon, ToolIcon, WriteActionIcon, -} from "./atoms/icons"; +} from "../atoms/icons"; +import { DiffView } from "../molecules/DiffView"; +import { FileCreateView } from "../molecules/FileCreateView"; +import { TodoView } from "../molecules/TodoView"; type Props = { part: ToolPart; @@ -23,17 +24,9 @@ type Props = { /** タイトル(ファイルパスなど)をフォーマット */ function formatTitle(part: ToolPart): string | null { - const { state, tool } = part; + const { state } = part; const title = state.status === "completed" ? state.title : state.status === "running" ? state.title : null; - if (!title) return null; - // bash の場合はコマンド説明 - if (tool === "bash" || tool === "task") return title; - return title; -} - -/** ファイルパスの末尾ファイル名を取得 */ -function _basename(p: string): string { - return p.split("/").pop() ?? p; + return title ?? null; } /** input からファイルパスを抽出 */ @@ -51,81 +44,6 @@ function isFileCreateInput(input: Record): boolean { return typeof input.content === "string" && getFilePath(input) !== null && typeof input.oldString !== "string"; } -function TodoView({ todos }: { todos: TodoItem[] }) { - const t = useLocale(); - const completed = todos.filter((td) => td.status === "completed" || td.status === "done").length; - const total = todos.length; - - return ( -
    -
    {t["tool.completed"](completed, total)}
    -
      - {todos.map((todo, i) => { - const isDone = todo.status === "completed" || todo.status === "done"; - const priorityClass = todo.priority === "high" ? "high" : todo.priority === "low" ? "low" : ""; - return ( -
    • - {isDone ? "✓" : "○"} - {todo.content} - {todo.priority && {todo.priority}} -
    • - ); - })} -
    -
    - ); -} - -// --- サブコンポーネント --- - -function DiffView({ oldStr, newStr }: { oldStr: string; newStr: string }) { - const lines = useMemo(() => computeLineDiff(oldStr, newStr), [oldStr, newStr]); - const addCount = lines.filter((l) => l.type === "add").length; - const removeCount = lines.filter((l) => l.type === "remove").length; - - return ( -
    -
    - {addCount > 0 && +{addCount}} - {removeCount > 0 && −{removeCount}} -
    -
    - {lines.map((line, i) => ( -
    - - {line.type === "add" ? "+" : line.type === "remove" ? "−" : " "} - - {line.text || "\u00A0"} -
    - ))} -
    -
    - ); -} - -function FileCreateView({ content }: { content: string }) { - const t = useLocale(); - const lines = content.split("\n"); - const displayLines = lines.length > 30 ? [...lines.slice(0, 30), t["tool.moreLines"](lines.length - 30)] : lines; - return ( -
    -
    - {t["tool.addLines"](lines.length)} -
    -
    - {displayLines.map((line, i) => ( -
    - + - {line || "\u00A0"} -
    - ))} -
    -
    - ); -} - -// --- アクションアイコン --- - function ActionIcon({ category }: { category: ToolCategory }) { switch (category) { case "read": @@ -143,8 +61,6 @@ function ActionIcon({ category }: { category: ToolCategory }) { } } -// --- メインコンポーネント --- - export function ToolPartView({ part }: Props) { const t = useLocale(); const [expanded, setExpanded] = useState(false); From 04fda3edf9d18e737aff7b021c6d2956399162fc Mon Sep 17 00:00:00 2001 From: ktmage Date: Thu, 26 Feb 2026 04:01:57 +0900 Subject: [PATCH 11/84] feat: add ContextIndicator, InputArea, MessageItem, MessagesArea, and TextPartView components with functionality for message handling and context management --- webview/components/ContextIndicator.tsx | 120 +++++++ webview/components/InputArea.tsx | 452 ++++++++++++++++++++++++ webview/components/MessageItem.tsx | 182 ++++++++++ webview/components/MessagesArea.tsx | 81 +++++ webview/components/TextPartView.tsx | 21 ++ 5 files changed, 856 insertions(+) create mode 100644 webview/components/ContextIndicator.tsx create mode 100644 webview/components/InputArea.tsx create mode 100644 webview/components/MessageItem.tsx create mode 100644 webview/components/MessagesArea.tsx create mode 100644 webview/components/TextPartView.tsx diff --git a/webview/components/ContextIndicator.tsx b/webview/components/ContextIndicator.tsx new file mode 100644 index 0000000..5d2d5b9 --- /dev/null +++ b/webview/components/ContextIndicator.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocale } from "../locales"; + +type Props = { + inputTokens: number; + contextLimit: number; + onCompress: () => void; + isCompressing: boolean; +}; + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +export function ContextIndicator({ inputTokens, contextLimit, onCompress, isCompressing }: Props) { + const t = useLocale(); + const [showPopup, setShowPopup] = useState(false); + const containerRef = useRef(null); + + // 外部クリックでポップアップを閉じる + useEffect(() => { + if (!showPopup) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowPopup(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showPopup]); + + const handleClick = useCallback(() => { + setShowPopup((s) => !s); + }, []); + + if (contextLimit <= 0) return null; + + const ratio = Math.min(inputTokens / contextLimit, 1); + const percent = Math.round(ratio * 100); + + // 0% の場合はボタン自体を非表示 + if (percent === 0) return null; + + // 円形プログレスリング (SVG) + const size = 22; + const strokeWidth = 2.5; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const dashOffset = circumference * (1 - ratio); + + // 使用率に応じた色 + const color = percent >= 80 ? "var(--vscode-editorWarning-foreground)" : "var(--vscode-textLink-foreground)"; + + return ( +
    + + + {showPopup && ( +
    +
    {t["context.windowUsage"]}
    +
    + {t["context.inputTokens"]} + {formatTokenCount(inputTokens)} +
    +
    + {t["context.contextLimit"]} + {formatTokenCount(contextLimit)} +
    +
    +
    +
    + +
    + )} +
    + ); +} diff --git a/webview/components/InputArea.tsx b/webview/components/InputArea.tsx new file mode 100644 index 0000000..ee0d483 --- /dev/null +++ b/webview/components/InputArea.tsx @@ -0,0 +1,452 @@ +import type { Provider } from "@opencode-ai/sdk"; +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import type { LocaleSetting } from "../locales"; +import { useLocale } from "../locales"; +import type { AllProvidersData, FileAttachment } from "../vscode-api"; +import { postMessage } from "../vscode-api"; +import { + ChevronRightIcon, + ClipIcon, + CloseIcon, + GearIcon, + PlusIcon, + SendIcon, + StopIcon, + TerminalIcon, +} from "./atoms/icons"; +import { ContextIndicator } from "./atoms/ContextIndicator"; +import { ModelSelector } from "./ModelSelector"; +import { ToolConfigPanel } from "./ToolConfigPanel"; + +type Props = { + onSend: (text: string, files: FileAttachment[]) => void; + onAbort: () => void; + isBusy: boolean; + providers: Provider[]; + allProvidersData: AllProvidersData | null; + selectedModel: { providerID: string; modelID: string } | null; + onModelSelect: (model: { providerID: string; modelID: string }) => void; + openEditors: FileAttachment[]; + workspaceFiles: FileAttachment[]; + inputTokens: number; + contextLimit: number; + onCompress: () => void; + isCompressing: boolean; + prefillText?: string; + onPrefillConsumed?: () => void; + openCodePaths: { home: string; config: string; state: string; directory: string } | null; + onOpenConfigFile: (filePath: string) => void; + onOpenTerminal: () => void; + localeSetting: LocaleSetting; + onLocaleSettingChange: (setting: LocaleSetting) => void; +}; + +export function InputArea({ + onSend, + onAbort, + isBusy, + providers, + allProvidersData, + selectedModel, + onModelSelect, + openEditors, + workspaceFiles, + inputTokens, + contextLimit, + onCompress, + isCompressing, + prefillText, + onPrefillConsumed, + openCodePaths, + onOpenConfigFile, + onOpenTerminal, + localeSetting, + onLocaleSettingChange, +}: Props) { + const t = useLocale(); + const [text, setText] = useState(""); + const [attachedFiles, setAttachedFiles] = useState([]); + const [showFilePicker, setShowFilePicker] = useState(false); + const [filePickerQuery, setFilePickerQuery] = useState(""); + const [showToolConfig, setShowToolConfig] = useState(false); + // # トリガー用 + const [hashTrigger, setHashTrigger] = useState<{ active: boolean; startIndex: number }>({ + active: false, + startIndex: -1, + }); + const [hashQuery, setHashQuery] = useState(""); + const textareaRef = useRef(null); + const composingRef = useRef(false); + const filePickerRef = useRef(null); + const hashPopupRef = useRef(null); + + // チェックポイントからの復元時にテキストをプリフィルする + useEffect(() => { + if (prefillText) { + setText(prefillText); + onPrefillConsumed?.(); + // テキストエリアの高さを調整してフォーカスする + requestAnimationFrame(() => { + const el = textareaRef.current; + if (el) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + el.focus(); + } + }); + } + }, [prefillText, onPrefillConsumed]); + + // クリップボタンを押したときにエディタ一覧を取得してファイルピッカーを開く + const handleClipClick = useCallback(() => { + postMessage({ type: "getOpenEditors" }); + postMessage({ type: "searchWorkspaceFiles", query: "" }); + setShowFilePicker((s) => !s); + setFilePickerQuery(""); + }, []); + + // ファイルピッカー内の検索 + const handleFilePickerSearch = useCallback((q: string) => { + setFilePickerQuery(q); + postMessage({ type: "searchWorkspaceFiles", query: q }); + }, []); + + // ファイルを添付する + const addFile = useCallback( + (file: FileAttachment) => { + setAttachedFiles((prev) => { + if (prev.some((f) => f.filePath === file.filePath)) return prev; + return [...prev, file]; + }); + setShowFilePicker(false); + // # トリガーの場合はテキストから #query を消す + if (hashTrigger.active) { + setText((prev) => { + const before = prev.slice(0, hashTrigger.startIndex); + const after = prev.slice(hashTrigger.startIndex + 1 + hashQuery.length); + return before + after; + }); + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + } + textareaRef.current?.focus(); + }, + [hashTrigger, hashQuery], + ); + + // ファイルを添付解除する + const removeFile = useCallback((filePath: string) => { + setAttachedFiles((prev) => prev.filter((f) => f.filePath !== filePath)); + }, []); + + // 外部クリックでファイルピッカーを閉じる + useEffect(() => { + if (!showFilePicker) return; + const handler = (e: MouseEvent) => { + if (filePickerRef.current && !filePickerRef.current.contains(e.target as Node)) { + setShowFilePicker(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showFilePicker]); + + // 外部クリックで # ポップアップを閉じる + useEffect(() => { + if (!hashTrigger.active) return; + const handler = (e: MouseEvent) => { + if ( + hashPopupRef.current && + !hashPopupRef.current.contains(e.target as Node) && + textareaRef.current && + !textareaRef.current.contains(e.target as Node) + ) { + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [hashTrigger.active]); + + // # トリガー: ワークスペースファイルを検索する + useEffect(() => { + if (hashTrigger.active) { + postMessage({ type: "searchWorkspaceFiles", query: hashQuery }); + } + }, [hashTrigger.active, hashQuery]); + + const handleSend = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + onSend(trimmed, attachedFiles); + setText(""); + setAttachedFiles([]); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }, [text, attachedFiles, onSend]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Escape で # ポップアップを閉じる + if (e.key === "Escape" && hashTrigger.active) { + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + return; + } + // IME 変換中は送信しない + if (e.key === "Enter" && !e.shiftKey && !composingRef.current) { + e.preventDefault(); + if (isBusy) return; + // # ポップアップ表示中はファイル選択ではなく送信を優先 + if (hashTrigger.active) { + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + } + handleSend(); + } + }, + [handleSend, isBusy, hashTrigger.active], + ); + + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + const newText = e.target.value; + setText(newText); + + // # トリガー検出 + const cursorPos = e.target.selectionStart; + if (newText.length > text.length) { + // 文字追加時 + const addedChar = newText[cursorPos - 1]; + if ( + addedChar === "#" && + (cursorPos === 1 || newText[cursorPos - 2] === " " || newText[cursorPos - 2] === "\n") + ) { + // # の前が空白・改行・先頭の場合にトリガーを開始 + setHashTrigger({ active: true, startIndex: cursorPos - 1 }); + setHashQuery(""); + postMessage({ type: "getOpenEditors" }); + return; + } + } + + // # トリガーがアクティブなら、クエリを更新する + if (hashTrigger.active) { + const queryPart = newText.slice(hashTrigger.startIndex + 1, cursorPos); + // スペースまたは改行が含まれたらトリガー終了 + if (/[\s]/.test(queryPart) || cursorPos <= hashTrigger.startIndex) { + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + } else { + setHashQuery(queryPart); + } + } + }, + [text, hashTrigger], + ); + + const handleInput = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, []); + + // ファイルピッカーに表示するリスト: 検索クエリがあればworkspaceFiles、なければopenEditors + workspaceFiles + const pickerFiles = filePickerQuery + ? workspaceFiles.filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))].filter( + (f) => !attachedFiles.some((a) => a.filePath === f.filePath), + ); + + // # トリガーのファイル候補 + const hashFiles = hashQuery + ? workspaceFiles + .filter( + (f) => + f.fileName.toLowerCase().includes(hashQuery.toLowerCase()) || + f.filePath.toLowerCase().includes(hashQuery.toLowerCase()), + ) + .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + .slice(0, 10) + : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))] + .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + .slice(0, 10); + + // 現在アクティブなエディタファイル (リストの先頭) + const activeEditorFile = openEditors.length > 0 ? openEditors[0] : null; + const isActiveAttached = activeEditorFile + ? attachedFiles.some((f) => f.filePath === activeEditorFile.filePath) + : false; + + return ( +
    +
    + {/* コンテキストバー: クリップボタン + 添付ファイルチップ + quick-add を1行に */} +
    +
    + {/* クリップボタン */} +
    + + {showFilePicker && ( +
    + handleFilePickerSearch(e.target.value)} + /> +
    + {pickerFiles.length > 0 ? ( + pickerFiles.slice(0, 15).map((file) => ( +
    addFile(file)}> + {file.fileName} + {file.filePath} +
    + )) + ) : ( +
    {t["input.noFiles"]}
    + )} +
    +
    + )} +
    + {/* 添付されたファイルチップ (インライン) */} + {attachedFiles.map((file) => ( +
    + {file.fileName} + +
    + ))} + {/* 現在開いているファイルの quick-add ボタン */} + {activeEditorFile && !isActiveAttached && ( + + )} +
    + {/* コンテキストウィンドウ使用率インジケーター (右側) */} + {contextLimit > 0 && ( + + )} +
    + + {/* テキスト入力エリア(# ポップアップ付き) */} +
    +