diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 113c5a3..334494a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,4 +20,7 @@ jobs: - run: npm ci + - name: Lint & Format check + run: npx biome check . + - run: npm test diff --git a/.vscode/settings.json b/.vscode/settings.json index fa7bfbf..c8565c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,11 +33,27 @@ "https://*.wikipedia.org": true, "https://*.netlify.app": true, "https://*.hotwired.dev": true, - "https://*.opencode.ai": true + "https://*.opencode.ai": true, + "https://*.visualstudio.com": true, + "https://*.zipcloud.ibsnet.co.jp": true, + "https://*.google.com": true }, "chat.instructionsFilesLocations": { ".ktmage/instructions": true, ".ktmage/project/instructions": true }, - "chat.agent.maxRequests": 100 + "chat.agent.maxRequests": 100, + "accessibility.signalOptions.volume": 70, + "accessibility.signals.chatEditModifiedFile": { + "sound": "on" + }, + "accessibility.signals.chatRequestSent": { + "sound": "on" + }, + "accessibility.signals.chatResponseReceived": { + "sound": "on" + }, + "accessibility.signals.chatUserActionRequired": { + "sound": "on" + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b2320..e6eb6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +## [0.2.0] - 2026-03-01 + +### Added + +- Message editing & checkpoint restore +- Reasoning / thinking display +- Shell command execution +- File changes diff view +- Session fork +- Child session navigation (subtask) +- Subtask display for task tool calls +- Agent mention (`@` mention) +- Session sharing +- Undo / Redo +- Settings panel +- Keyboard navigation for inline popups (Tab / Arrow keys) +- Biome as linter/formatter +- DOMPurify for XSS protection +- CSS Modules for component styling + +### Changed + +- Replace hardcoded SVG icons with react-icons/vsc +- UI component architecture refactored to Atoms/Molecules/Organisms +- Todo display migrated from message parsing to session.todo() API +- OpenCode repository URL updated (opencode-ai → anomalyco) +- Repository URL updated to opencode-gui + +### Fixed + +- Markdown CSS scoped to `.markdown` class to prevent style bleeding + ## [0.1.0] - 2026-02-24 ### Added @@ -19,3 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Context compression indicator - Todo display - i18n support (English, Japanese) + +[Unreleased]: https://github.com/ktmage/opencode-gui/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/ktmage/opencode-gui/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/ktmage/opencode-gui/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8ac246..2cb435d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,13 @@ This project itself is developed primarily through AI-assisted coding, and we we - Node.js v22+ - npm -- [OpenCode](https://github.com/opencode-ai/opencode) installed with LLM provider authentication configured +- [OpenCode](https://github.com/anomalyco/opencode) installed with LLM provider authentication configured #### Setup ```sh -git clone https://github.com//OpenCodeGUI.git -cd OpenCodeGUI +git clone https://github.com//opencode-gui.git +cd opencode-gui npm install npm run build ``` @@ -45,7 +45,7 @@ npm test #### Reporting Bugs / Requesting Features -Opening an issue before starting work is recommended. Use the provided [issue templates](https://github.com/ktmage/OpenCodeGUI/issues/new/choose). +Opening an issue before starting work is recommended. Use the provided [issue templates](https://github.com/ktmage/opencode-gui/issues/new/choose). For small fixes (typos, documentation improvements), you may open a PR directly. @@ -54,18 +54,33 @@ For small fixes (typos, documentation improvements), you may open a PR directly. 1. Fork the repository 2. Create a branch from `development` 3. Make your changes -4. Ensure `npm run build` and `npm test` pass +4. Ensure `npm run check`, `npm run build` and `npm test` pass 5. Open a pull request against `development` PRs are squash-merged to keep the commit history clean. ### Code Style -There is no formal linter or formatter configured yet. Please follow the style of the existing codebase. +This project uses [Biome](https://biomejs.dev/) for linting and formatting. Run the following before submitting a PR: + +```sh +npm run check +``` + +To auto-fix issues: + +```sh +npm run check:fix +``` ### Testing -When your change affects behavior, add or update scenario tests. Follow the existing test patterns in `webview/__tests__/scenarios/`. +When your change affects behavior, add or update tests. Follow existing test patterns: + +- **Scenario tests:** `webview/__tests__/scenarios/` +- **Component unit tests:** `webview/__tests__/components/` +- **Hook unit tests:** `webview/__tests__/hooks/` +- **Utility tests:** `webview/__tests__/utils/` ### Review Process @@ -92,13 +107,13 @@ By submitting a pull request, you agree that your contributions are licensed und - Node.js v22+ - npm -- [OpenCode](https://github.com/opencode-ai/opencode) がインストール済みで、LLM プロバイダの認証が完了していること +- [OpenCode](https://github.com/anomalyco/opencode) がインストール済みで、LLM プロバイダの認証が完了していること #### セットアップ ```sh -git clone https://github.com//OpenCodeGUI.git -cd OpenCodeGUI +git clone https://github.com//opencode-gui.git +cd opencode-gui npm install npm run build ``` @@ -113,7 +128,7 @@ npm test #### バグ報告・機能リクエスト -作業を開始する前に Issue を立てることを推奨します。[Issue テンプレート](https://github.com/ktmage/OpenCodeGUI/issues/new/choose)を利用してください。 +作業を開始する前に Issue を立てることを推奨します。[Issue テンプレート](https://github.com/ktmage/opencode-gui/issues/new/choose)を利用してください。 小さな修正(typo、ドキュメント改善など)は直接 PR を出しても構いません。 @@ -122,18 +137,33 @@ npm test 1. リポジトリをフォークする 2. `development` ブランチからブランチを作成する 3. 変更を加える -4. `npm run build` と `npm test` が通ることを確認する +4. `npm run check`、`npm run build`、`npm test` が通ることを確認する 5. `development` ブランチに対して Pull Request を出す PR は Squash merge でマージされます。 ### コードスタイル -現在、Linter や Formatter は導入していません。既存のコードベースのスタイルに合わせてください。 +本プロジェクトでは [Biome](https://biomejs.dev/) を Linter/Formatter として使用しています。PR を出す前に以下を実行してください: + +```sh +npm run check +``` + +問題を自動修正するには: + +```sh +npm run check:fix +``` ### テスト -動作に影響する変更の場合は、シナリオテストを追加・更新してください。`webview/__tests__/scenarios/` にある既存のテストパターンに習って書いてください。 +動作に影響する変更の場合は、テストを追加・更新してください。既存のテストパターンに習って書いてください: + +- **シナリオテスト:** `webview/__tests__/scenarios/` +- **コンポーネント単体テスト:** `webview/__tests__/components/` +- **フック単体テスト:** `webview/__tests__/hooks/` +- **ユーティリティテスト:** `webview/__tests__/utils/` ### レビュー体制 diff --git a/README.md b/README.md index 2541c02..1783a7f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCodeGUI -An unofficial VS Code sidebar chat interface for [OpenCode](https://github.com/opencode-ai/opencode). +An unofficial VS Code sidebar chat interface for [OpenCode](https://github.com/anomalyco/opencode). OpenCode の非公式 VS Code サイドバーチャットインターフェース。 @@ -22,6 +22,10 @@ Use all OpenCode features from a familiar sidebar chat UI. > **Disclaimer:** > This project is experimental and developed primarily through AI-assisted coding. It is provided "as-is" without warranty of any kind. It may contain unexpected behavior, unconventional implementations, or undiscovered defects. Use at your own risk. The authors assume no liability for any damages arising from the use of this software. +### Demo + +![Demo](media/demo.gif) + ### Documents | File | Description | @@ -37,16 +41,27 @@ Use all OpenCode features from a familiar sidebar chat UI. - Markdown rendering - Tool call collapsible display - Permission approval UI (Allow / Once / Deny) -- Session management (create, switch, delete) +- Session management (create, switch, fork, delete) +- Message editing & checkpoint restore - Model selection - File context attachment +- File changes diff view +- Shell command execution - Context compression indicator +- Reasoning / thinking display - Todo display +- Undo / Redo +- Session sharing +- Agent mention (`@` mention) +- Child session navigation (subtask) +- Settings panel +- Keyboard navigation for inline popups (Tab / Arrow keys) +- Subtask display - i18n support (English, Japanese) ### Requirements -- [OpenCode](https://github.com/opencode-ai/opencode) installed +- [OpenCode](https://github.com/anomalyco/opencode) installed - LLM provider authentication configured in OpenCode ### Installation @@ -92,6 +107,12 @@ npm run watch:ext npm run watch:webview ``` +#### Lint & Format + +```sh +npm run check +``` + #### Debug 1. Run `npm run build` @@ -116,12 +137,12 @@ webview/ # Webview (Browser, React) main.tsx # React entry point App.tsx # State management & SSE event handling vscode-api.ts # VS Code Webview API wrapper - styles.css # Styles using VS Code theme variables - components/ # React components - -dist/ # Build output (not tracked by git) - extension.js # Extension bundle - webview/ # Webview bundle + components/ # React components (CSS Modules) + hooks/ # Custom React hooks + contexts/ # React Context providers + locales/ # i18n locale files + utils/ # Utility functions + __tests__/ # Tests (unit, scenario) esbuild.mjs # Extension build config vite.config.ts # Webview build config @@ -148,6 +169,10 @@ OpenCode の全機能をサイドバーのチャット UI から操作できま > **免責事項:** > 本プロジェクトは実験的な取り組みであり、主に AI を活用したコーディングにより開発されています。いかなる保証もなく「現状のまま」提供されます。予期しない動作、一般的でない実装、未発見の不具合が含まれる可能性があります。ご利用は自己責任でお願いいたします。本ソフトウェアの使用により生じたいかなる損害についても、作者は一切の責任を負いません。 +### デモ + +![デモ](media/demo.gif) + ### ドキュメント | ファイル | 説明 | @@ -163,16 +188,27 @@ OpenCode の全機能をサイドバーのチャット UI から操作できま - Markdown レンダリング - ツールコールの折りたたみ表示 - パーミッション承認 UI(Allow / Once / Deny) -- セッション管理(作成、切替、削除) +- セッション管理(作成、切替、フォーク、削除) +- メッセージ編集とチェックポイント復元 - モデル選択 - ファイルコンテキスト添付 +- ファイル変更差分表示 +- シェルコマンド実行 - コンテキスト圧縮インジケーター +- 推論(思考過程)表示 - Todo 表示 +- Undo / Redo +- セッション共有 +- エージェントメンション(`@` メンション) +- 子セッションナビゲーション(サブタスク) +- 設定パネル +- インラインポップアップのキーボードナビゲーション(Tab / 矢印キー) +- サブタスク表示 - 多言語対応(英語、日本語) ### 必要条件 -- [OpenCode](https://github.com/opencode-ai/opencode) がインストール済みであること +- [OpenCode](https://github.com/anomalyco/opencode) がインストール済みであること - OpenCode 側で LLM プロバイダの認証が完了していること ### インストール @@ -218,6 +254,12 @@ npm run watch:ext npm run watch:webview ``` +#### リント & フォーマット + +```sh +npm run check +``` + #### デバッグ実行 1. `npm run build` でビルドする @@ -238,16 +280,16 @@ src/ # Extension Host (Node.js) opencode-client.ts # OpenCode サーバー接続 chat-view-provider.ts # Webview パネル & メッセージングプロトコル -webview/ # Webview (ブラウザ, React) +webview/ # Webview (Browser, React) main.tsx # React エントリーポイント App.tsx # 状態管理 & SSE イベントハンドリング vscode-api.ts # VS Code Webview API ラッパー - styles.css # VS Code テーマ変数を使ったスタイル - components/ # React コンポーネント - -dist/ # ビルド出力(git 管理外) - extension.js # Extension バンドル - webview/ # Webview バンドル + components/ # React コンポーネント(CSS Modules) + hooks/ # カスタム React フック + contexts/ # React Context プロバイダー + locales/ # i18n ロケールファイル + utils/ # ユーティリティ関数 + __tests__/ # テスト(単体、シナリオ) esbuild.mjs # Extension ビルド設定 vite.config.ts # Webview ビルド設定 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c12e4d0..90c88ea 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -95,6 +95,28 @@ SOFTWARE. --- +## DOMPurify + +- **Version:** 3.3.1 +- **License:** Apache-2.0 OR MPL-2.0 (distributed under Apache-2.0) +- **Source:** https://github.com/cure53/DOMPurify + +Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + ## Marked - **Version:** 17.0.3 @@ -121,3 +143,35 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## react-icons (VS Code Icons subset) + +- **Version:** 5.5.0 +- **License:** MIT +- **Source:** https://github.com/react-icons/react-icons + +Copyright 2018 kamijin_fanta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This project uses only the VS Code Icons (vsc prefix) from react-icons. +VS Code Icons are sourced from [vscode-codicons](https://github.com/microsoft/vscode-codicons) +and licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..fb33fb9 --- /dev/null +++ b/biome.json @@ -0,0 +1,57 @@ +{ + "$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, + "suspicious": { + "noDuplicateTestHooks": "off", + "noExplicitAny": "warn", + "noArrayIndexKey": "warn" + }, + "style": { + "noNonNullAssertion": "warn" + }, + "a11y": { + "useKeyWithClickEvents": "warn", + "noStaticElementInteractions": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, + "css": { + "linter": { + "enabled": true + }, + "formatter": { + "enabled": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} 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/media/demo.gif b/media/demo.gif new file mode 100644 index 0000000..5147b13 Binary files /dev/null and b/media/demo.gif differ diff --git a/package-lock.json b/package-lock.json index c24dd32..2cbb088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,16 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "^1.2.10" + "@opencode-ai/sdk": "^1.2.10", + "dompurify": "^3.3.1", + "react-icons": "^5.5.0" }, "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", + "@types/dompurify": "^3.0.5", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -585,6 +589,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", @@ -2081,6 +2248,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2135,6 +2312,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.109.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", @@ -3369,6 +3553,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -5479,7 +5672,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -5500,6 +5692,15 @@ "react": "^19.2.4" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 9be6bed..b4d8b06 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,16 @@ "name": "opencodegui", "displayName": "%displayName%", "description": "%description%", - "version": "0.1.0", + "version": "0.2.0", "publisher": "ktmage", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/ktmage/OpenCodeGUI" + "url": "https://github.com/ktmage/opencode-gui" }, - "homepage": "https://github.com/ktmage/OpenCodeGUI#readme", + "homepage": "https://github.com/ktmage/opencode-gui#readme", "bugs": { - "url": "https://github.com/ktmage/OpenCodeGUI/issues" + "url": "https://github.com/ktmage/opencode-gui/issues" }, "keywords": [ "opencode", @@ -62,15 +62,20 @@ "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", + "@types/dompurify": "^3.0.5", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -87,6 +92,8 @@ "vitest": "^4.0.18" }, "dependencies": { - "@opencode-ai/sdk": "^1.2.10" + "@opencode-ai/sdk": "^1.2.10", + "dompurify": "^3.3.1", + "react-icons": "^5.5.0" } } diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index 313f52b..ac3e9ca 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -1,7 +1,19 @@ +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 { + Agent, + Event, + FileDiff, + Message, + OpenCodeConnection, + OpenCodePath, + Part, + Provider, + ProviderListResult, + Session, + Todo, +} from "./opencode-client"; // --- File attachment --- type FileAttachment = { @@ -15,17 +27,34 @@ 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 } } | { type: "toolConfig"; paths: OpenCodePath } | { type: "locale"; vscodeLanguage: string } - | { type: "modelUpdated"; model: string; default: Record }; + | { type: "modelUpdated"; model: string; default: Record } + | { type: "sessionDiff"; sessionId: string; diffs: FileDiff[] } + | { type: "sessionTodos"; sessionId: string; todos: Todo[] } + | { type: "childSessions"; sessionId: string; children: Session[] } + | { type: "agents"; agents: Agent[] }; // --- 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[]; + agent?: string; + } | { type: "createSession"; title?: string } | { type: "listSessions" } | { type: "selectSession"; sessionId: string } @@ -38,10 +67,28 @@ 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: "executeShell"; sessionId: string; command: string; model?: { providerID: string; modelID: string } } | { type: "openConfigFile"; filePath: string } | { type: "openTerminal" } | { type: "setModel"; model: string } + | { type: "forkSession"; sessionId: string; messageId?: string } + | { type: "getSessionDiff"; sessionId: string } + | { type: "getSessionTodos"; sessionId: string } + | { type: "getChildSessions"; sessionId: string } + | { type: "getAgents" } + | { type: "shareSession"; sessionId: string } + | { type: "unshareSession"; sessionId: string } + | { type: "undoSession"; sessionId: string; messageId: string } + | { type: "redoSession"; sessionId: string } + | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; export class ChatViewProvider implements vscode.WebviewViewProvider { @@ -66,16 +113,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,12 +157,18 @@ 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; } case "sendMessage": { - await this.connection.sendMessage(message.sessionId, message.text, message.model, message.files); + await this.connection.sendMessage(message.sessionId, message.text, message.model, message.files, message.agent); break; } case "createSession": { @@ -159,11 +208,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 +228,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": { @@ -239,6 +290,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await this.connection.sendMessage(message.sessionId, message.text, message.model, message.files); break; } + case "executeShell": { + await this.connection.executeShell(message.sessionId, message.command, message.model); + break; + } case "openConfigFile": { const filePath = message.filePath; const uri = vscode.Uri.file(filePath); @@ -248,7 +303,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,10 +340,83 @@ 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; } + case "forkSession": { + // Fork で新しいセッションを作成し、アクティブセッションを切り替える + const forkedSession = await this.connection.forkSession(message.sessionId, message.messageId); + this.activeSession = forkedSession; + this.postMessage({ type: "activeSession", session: forkedSession }); + const forkedSessions = await this.connection.listSessions(); + this.postMessage({ type: "sessions", sessions: forkedSessions }); + break; + } + case "getSessionDiff": { + const diffs = await this.connection.getSessionDiff(message.sessionId); + this.postMessage({ type: "sessionDiff", sessionId: message.sessionId, diffs }); + break; + } + case "getSessionTodos": { + const todos = await this.connection.getSessionTodos(message.sessionId); + this.postMessage({ type: "sessionTodos", sessionId: message.sessionId, todos }); + break; + } + case "getChildSessions": { + const children = await this.connection.getChildSessions(message.sessionId); + this.postMessage({ type: "childSessions", sessionId: message.sessionId, children }); + break; + } + case "getAgents": { + const agents = await this.connection.getAgents(); + this.postMessage({ type: "agents", agents }); + break; + } + case "shareSession": { + const session = await this.connection.shareSession(message.sessionId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + // 共有 URL をクリップボードにコピーする + if (session.share?.url) { + await vscode.env.clipboard.writeText(session.share.url); + } + break; + } + case "unshareSession": { + const session = await this.connection.unshareSession(message.sessionId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + break; + } + case "undoSession": { + const session = await this.connection.revertSession(message.sessionId, message.messageId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + const messages = await this.connection.getMessages(message.sessionId); + this.postMessage({ type: "messages", sessionId: message.sessionId, messages }); + break; + } + case "redoSession": { + const session = await this.connection.unrevertSession(message.sessionId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + const messages = await this.connection.getMessages(message.sessionId); + this.postMessage({ type: "messages", sessionId: message.sessionId, messages }); + break; + } + case "openDiffEditor": { + // 仮想ドキュメントを使って VS Code のネイティブ diff エディタを開く + const beforeUri = vscode.Uri.parse( + `opencode-diff-before:${message.filePath}?${encodeURIComponent(message.before)}`, + ); + const afterUri = vscode.Uri.parse( + `opencode-diff-after:${message.filePath}?${encodeURIComponent(message.after)}`, + ); + const fileName = path.basename(message.filePath); + await vscode.commands.executeCommand("vscode.diff", beforeUri, afterUri, `${fileName} (Changes)`); + break; + } } } @@ -297,12 +428,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..9b4a022 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/anomalyco/opencode', ), ); return; @@ -42,16 +39,21 @@ 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)); + // diff エディタ用の仮想ドキュメントプロバイダー。 + // URI のクエリ部分にエンコードされたコンテンツを返す。 + const diffContentProvider: vscode.TextDocumentContentProvider = { + provideTextDocumentContent(uri: vscode.Uri): string { + return decodeURIComponent(uri.query); + }, + }; context.subscriptions.push( - new vscode.Disposable(() => connection.disconnect()), + vscode.workspace.registerTextDocumentContentProvider("opencode-diff-before", diffContentProvider), + vscode.workspace.registerTextDocumentContentProvider("opencode-diff-after", diffContentProvider), ); + + 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..5210cf2 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -1,21 +1,37 @@ -import * as path from "path"; +import * as path from "node:path"; import { + type Agent, + type Config, createOpencodeClient, createOpencodeServer, - type OpencodeClient, type Event, - type Session, + type FileDiff, + type McpStatus, type Message, + type Path as OpenCodePath, + type OpencodeClient, type Part, type Provider, - type McpStatus, + type Session, + type Todo, type ToolListItem, - type Config, - type Path as OpenCodePath, } from "@opencode-ai/sdk"; import * as vscode from "vscode"; -export type { Event, Session, Message, Part, Provider, McpStatus, ToolListItem, Config, OpenCodePath }; +export type { + Agent, + Event, + Session, + Message, + Part, + Provider, + McpStatus, + ToolListItem, + Config, + OpenCodePath, + FileDiff, + Todo, +}; // provider.list() が返す生データ型 export type ProviderListResult = { @@ -150,11 +166,18 @@ export class OpenCodeConnection { }); } + async forkSession(sessionId: string, messageId?: string): Promise { + const client = this.requireClient(); + const response = await client.session.fork({ + path: { id: sessionId }, + body: { messageID: messageId }, + }); + return response.data!; + } + // --- 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 }, @@ -171,11 +194,14 @@ export class OpenCodeConnection { text: string, model?: { providerID: string; modelID: string }, files?: Array<{ filePath: string; fileName: string }>, + agent?: string, ): Promise { const client = this.requireClient(); - const parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename: string }> = [ - { type: "text", text }, - ]; + const parts: Array< + | { type: "text"; text: string } + | { type: "file"; mime: string; url: string; filename: string } + | { type: "agent"; name: string } + > = [{ type: "text", text }]; if (files) { for (const file of files) { // filePath はワークスペース相対パス。cwd 基準で絶対パスに変換する。 @@ -190,6 +216,12 @@ export class OpenCodeConnection { }); } } + // @agent メンションはサブエージェント呼び出しを示す AgentPartInput として parts に含める。 + // body.agent はプロンプトを処理するエージェントの切り替え(primary agent 間)に使われるため、 + // サブエージェント起動には AgentPartInput を使う。 + if (agent) { + parts.push({ type: "agent", name: agent }); + } await client.session.promptAsync({ path: { id: sessionId }, body: { @@ -206,6 +238,20 @@ export class OpenCodeConnection { }); } + // --- Shell API --- + + async executeShell( + sessionId: string, + command: string, + model?: { providerID: string; modelID: string }, + ): Promise { + const client = this.requireClient(); + await client.session.shell({ + path: { id: sessionId }, + body: { agent: "default", command, model }, + }); + } + // --- Provider API --- async getProviders(): Promise<{ providers: Provider[]; default: Record }> { @@ -234,12 +280,65 @@ export class OpenCodeConnection { }); } + // --- Session Children API --- + + async getChildSessions(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.children({ + path: { id: sessionId }, + }); + return response.data!; + } + + // --- Session Todo API --- + + async getSessionTodos(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.todo({ + path: { id: sessionId }, + }); + return response.data!; + } + + // --- Session Share API --- + + async shareSession(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.share({ + path: { id: sessionId }, + }); + return response.data!; + } + + async unshareSession(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.unshare({ + path: { id: sessionId }, + }); + return response.data!; + } + + // --- Agent API --- + + async getAgents(): Promise { + const client = this.requireClient(); + const response = await client.app.agents(); + return response.data!; + } + + // --- Session Diff API --- + + async getSessionDiff(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.diff({ + path: { id: sessionId }, + }); + return response.data!; + } + // --- 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 }, @@ -248,12 +347,19 @@ export class OpenCodeConnection { return response.data!; } + // --- Unrevert API --- + + async unrevertSession(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.unrevert({ + path: { id: sessionId }, + }); + return response.data!; + } + // --- 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 +427,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..0d2b4b4 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()], @@ -7,7 +7,12 @@ export default defineConfig({ environment: "jsdom", root: "webview", setupFiles: ["./__tests__/setup.ts"], - include: ["./__tests__/**/*.test.tsx"], + include: ["./__tests__/**/*.test.{ts,tsx}"], globals: true, + css: { + modules: { + classNameStrategy: "non-scoped", + }, + }, }, }); diff --git a/webview/App.tsx b/webview/App.tsx index 320ab4f..8206b01 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,102 +1,128 @@ -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 { ChatHeader } from "./components/ChatHeader"; -import { MessagesArea } from "./components/MessagesArea"; -import { InputArea } from "./components/InputArea"; -import { EmptyState } from "./components/EmptyState"; -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 type { LocaleSetting } from "./locales"; - -export type MessageWithParts = { info: Message; parts: Part[] }; +import type { Agent, Event, Session, Todo } from "@opencode-ai/sdk"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { EmptyState } from "./components/molecules/EmptyState"; +import { FileChangesHeader } from "./components/molecules/FileChangesHeader"; +import { TodoHeader } from "./components/molecules/TodoHeader"; +import { ChatHeader } from "./components/organisms/ChatHeader"; +import { InputArea } from "./components/organisms/InputArea"; +import { MessagesArea } from "./components/organisms/MessagesArea"; +import { SessionList } from "./components/organisms/SessionList"; +import { AppContextProvider, type AppContextValue } from "./contexts/AppContext"; +import { useFileChanges } from "./hooks/useFileChanges"; +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"; + +// 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(); + const fileChanges = useFileChanges(); + + // Extension Host → Webview メッセージでのみ更新される単純なステート const [openEditors, setOpenEditors] = useState([]); const [workspaceFiles, setWorkspaceFiles] = useState([]); - // チェックポイントからの復元時にテキストを入力欄にプリフィルするためのステート - const [prefillText, setPrefillText] = useState(""); - // 設定パネル用のステート - const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; state: string; 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 [todos, setTodos] = useState([]); + const [childSessions, setChildSessions] = useState([]); + const [agents, setAgents] = useState([]); + const [openCodePaths, setOpenCodePaths] = useState<{ + home: string; + config: string; + state: string; + directory: string; + } | null>(null); + // handleEvent 内で activeSession を参照するために ref で追跡する。 + // これにより handleEvent の依存配列から session.activeSession(オブジェクト参照)を除外でき、 + // session.updated イベントのたびに useEffect が再登録される問題を防ぐ。 + const activeSessionRef = useRef(session.activeSession); + activeSessionRef.current = session.activeSession; - 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; - } + const handleOpenTerminal = useCallback(() => { + postMessage({ type: "openTerminal" }); + }, []); + + // SSE event handler — dispatches to domain-specific hooks + // activeSessionRef を使うことで session.activeSession(オブジェクト参照)への依存を排除し、 + // handleEvent を安定した参照に保つ。これにより useEffect の不要な再登録を防ぐ。 + const handleEvent = useCallback( + (event: Event) => { + session.handleSessionEvent(event); + msg.handleMessageEvent(event); + perm.handlePermissionEvent(event); + fileChanges.handleFileChangeEvent(event); + + const currentSession = activeSessionRef.current; + + // file.edited イベント時にセッション差分を再取得する + if (event.type === "file.edited" && currentSession) { + postMessage({ type: "getSessionDiff", sessionId: currentSession.id }); } - } - 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]); + // todo.updated イベント時にアクティブセッションの Todo を更新する + if (event.type === "todo.updated" && event.properties.sessionID === currentSession?.id) { + setTodos(event.properties.todos as Todo[]); + } + + // session.created イベント時にアクティブセッションの子セッションを再取得する + // (サブエージェントが起動すると子セッションが作成されるため) + if (event.type === "session.created" && currentSession) { + postMessage({ type: "getChildSessions", sessionId: currentSession.id }); + } + }, + [session.handleSessionEvent, msg.handleMessageEvent, perm.handlePermissionEvent, fileChanges.handleFileChangeEvent], + ); + + // 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 }); + postMessage({ type: "getSessionDiff", sessionId: data.session.id }); + postMessage({ type: "getSessionTodos", sessionId: data.session.id }); + postMessage({ type: "getChildSessions", sessionId: data.session.id }); } else { - setMessages([]); + msg.setMessages([]); + fileChanges.clearDiffs(); + setTodos([]); + setChildSessions([]); } 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(() => { - const modelStr = msg.configModel || msg.default["general"] || msg.default["code"] || Object.values(msg.default)[0]; + prov.setSelectedModel(() => { + const modelStr = + 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; @@ -108,305 +134,380 @@ 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; } - } - }; - window.addEventListener("message", handler); - 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)); + case "sessionDiff": { + if (data.sessionId === session.activeSession?.id) { + fileChanges.setDiffs(data.diffs); + } 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, - ); + case "sessionTodos": { + if (data.sessionId === session.activeSession?.id) { + setTodos(data.todos); + } break; } - case "session.created": { - setSessions((prev) => [event.properties.info, ...prev]); + case "childSessions": { + if (data.sessionId === session.activeSession?.id) { + setChildSessions(data.children); + } break; } - case "session.deleted": { - const deletedId = event.properties.info.id; - setSessions((prev) => prev.filter((s) => s.id !== deletedId)); + case "agents": { + setAgents(data.agents); break; } } - }, - [], - ); + }; + window.addEventListener("message", handler); + postMessage({ type: "ready" }); + postMessage({ type: "getOpenEditors" }); + postMessage({ type: "getAgents" }); + return () => window.removeEventListener("message", handler); + }, [ + session.activeSession?.id, + handleEvent, + locale.setVscodeLanguage, + msg.setMessages, + fileChanges.setDiffs, + fileChanges.clearDiffs, + prov.setAllProvidersData, + prov.setProviders, + prov.setSelectedModel, + session.setActiveSession, + session.setSessions, + ]); + + // Cross-cutting action handlers (span multiple hooks) const handleSend = useCallback( - (text: string, files: FileAttachment[]) => { - if (!activeSession) return; + (text: string, files: FileAttachment[], agent?: string) => { + if (!session.activeSession) return; postMessage({ type: "sendMessage", - sessionId: activeSession.id, + sessionId: session.activeSession.id, text, - model: selectedModel ?? undefined, + model: prov.selectedModel ?? undefined, files: files.length > 0 ? files : undefined, + agent, }); }, - [activeSession, selectedModel], + [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}` }); - }, []); + // ! プレフィクスで入力されたシェルコマンドを session.shell API 経由で実行する + const handleShellExecute = useCallback( + (command: string) => { + if (!session.activeSession) return; + // 次に到着する assistant メッセージをシェル結果としてタグ付けする準備 + msg.markPendingShell(); + postMessage({ + type: "executeShell", + sessionId: session.activeSession.id, + command, + model: prov.selectedModel ?? undefined, + }); + }, + [session.activeSession, prov.selectedModel, msg.markPendingShell], + ); 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; - } - } + const handleOpenDiffEditor = useCallback((filePath: string, before: string, after: string) => { + postMessage({ type: "openDiffEditor", filePath, before, after }); + }, []); + + // チェックポイントからセッションを Fork する + const handleForkFromCheckpoint = useCallback( + (messageId: string) => { + if (!session.activeSession) return; + postMessage({ + type: "forkSession", + sessionId: session.activeSession.id, + messageId, + }); + }, + [session.activeSession], + ); + + // セッションを共有する + const handleShareSession = useCallback(() => { + if (!session.activeSession) return; + postMessage({ type: "shareSession", sessionId: session.activeSession.id }); + }, [session.activeSession]); + + // セッションの共有を解除する + const handleUnshareSession = useCallback(() => { + if (!session.activeSession) return; + postMessage({ type: "unshareSession", sessionId: session.activeSession.id }); + }, [session.activeSession]); + + // Undo: 最後のアシスタントメッセージまで巻き戻す + // 巻き戻しで消えるユーザーメッセージのテキストを入力欄に復元する。 + const handleUndo = useCallback(() => { + if (!session.activeSession) return; + const messages = msg.messages; + // 最後のアシスタントメッセージを探す + const lastAssistantIdx = [...messages].reverse().findIndex((m) => m.info.role === "assistant"); + if (lastAssistantIdx < 0) return; + const assistantIdx = messages.length - 1 - lastAssistantIdx; + const lastAssistantMsg = messages[assistantIdx]; + + // アシスタントメッセージの直後にあるユーザーメッセージのテキストを取得する + const nextUserMsg = messages[assistantIdx + 1]; + if (nextUserMsg && nextUserMsg.info.role === "user") { + const textParts = nextUserMsg.parts.filter((p) => p.type === "text" && !(p as any).synthetic); + const fallback = textParts.length > 0 ? textParts : nextUserMsg.parts.filter((p) => p.type === "text"); + const text = fallback.map((p) => (p as any).text).join("") || ""; + msg.setPrefillText(text); + } else { + // ユーザーメッセージがない場合(アシスタントが最後のメッセージ)は空にしない + // revert はアシスタントメッセージ自体を消すので、その前のユーザーメッセージのテキストを復元する + const prevUserMsg = [...messages] + .slice(0, assistantIdx) + .reverse() + .find((m) => m.info.role === "user"); + if (prevUserMsg) { + const textParts = prevUserMsg.parts.filter((p) => p.type === "text" && !(p as any).synthetic); + const fallback = textParts.length > 0 ? textParts : prevUserMsg.parts.filter((p) => p.type === "text"); + const text = fallback.map((p) => (p as any).text).join("") || ""; + msg.setPrefillText(text); } } - return []; - }, [messages]); + + postMessage({ + type: "undoSession", + sessionId: session.activeSession.id, + messageId: lastAssistantMsg.info.id, + }); + }, [session.activeSession, msg.messages, msg.setPrefillText]); + + // Redo: Undo で取り消したメッセージを復元する + // メッセージが復元されるので入力欄のプリフィルをクリアする。 + const handleRedo = useCallback(() => { + if (!session.activeSession) return; + msg.setPrefillText(""); + postMessage({ type: "redoSession", sessionId: session.activeSession.id }); + }, [session.activeSession, msg.setPrefillText]); + + // Undo 可能判定: メッセージが 2 つ以上(ユーザー + アシスタント)あり、ビジーでない + const canUndo = msg.messages.length >= 2 && !session.sessionBusy; + // Redo 可能判定: session.revert フィールドが存在する(Undo 済みの状態) + const canRedo = !!session.activeSession?.revert && !session.sessionBusy; + + // 子セッションにナビゲートする + const handleNavigateToChild = useCallback( + (sessionId: string) => { + session.handleSelectSession(sessionId); + }, + [session.handleSelectSession], + ); + + // 親セッションに戻る + const handleNavigateToParent = useCallback(() => { + if (!session.activeSession?.parentID) return; + session.handleSelectSession(session.activeSession.parentID); + }, [session.activeSession, session.handleSelectSession]); + + // 子セッション閲覧中かの判定 + const isChildSession = !!session.activeSession?.parentID; + + const contextValue: AppContextValue = { + sessions: session.sessions, + activeSession: session.activeSession, + 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: todos, + 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, + fileDiffs: fileChanges.diffs, + onOpenDiffEditor: handleOpenDiffEditor, + onSend: handleSend, + onShellExecute: handleShellExecute, + isShellMessage: msg.isShellMessage, + onAbort: handleAbort, + onCompress: handleCompress, + isCompressing: !!session.activeSession?.time?.compacting, + onEditAndResend: handleEditAndResend, + onRevertToCheckpoint: handleRevertToCheckpoint, + onForkFromCheckpoint: handleForkFromCheckpoint, + openCodePaths, + onOpenConfigFile: handleOpenConfigFile, + onOpenTerminal: handleOpenTerminal, + localeSetting: locale.localeSetting, + onLocaleSettingChange: locale.handleLocaleSettingChange, + childSessions, + onNavigateToChild: handleNavigateToChild, + onNavigateToParent: handleNavigateToParent, + }; return ( - -
- setShowSessionList((s) => !s)} - /> - {showSessionList && ( - setShowSessionList(false)} - /> - )} - {activeSession ? ( - <> - - {latestTodos.length > 0 && } - setPrefillText("")} - openCodePaths={openCodePaths} - onOpenConfigFile={handleOpenConfigFile} - onOpenTerminal={handleOpenTerminal} - localeSetting={localeSetting} - onLocaleSettingChange={handleLocaleSettingChange} + + +
+ 0 ? handleShareSession : undefined} + onUnshareSession={handleUnshareSession} + onNavigateToParent={isChildSession ? handleNavigateToParent : undefined} + onUndo={handleUndo} + onRedo={handleRedo} + canUndo={canUndo} + canRedo={canRedo} + isBusy={session.sessionBusy} /> - - ) : ( - - )} -
+ {session.showSessionList && ( + + )} + {session.activeSession ? ( + <> + + {todos.length > 0 && } + {fileChanges.diffs.length > 0 && ( + + )} + {!isChildSession && ( + + )} + + ) : ( + + )} +
+
); } diff --git a/webview/__tests__/components/atoms/ActionButton.test.tsx b/webview/__tests__/components/atoms/ActionButton.test.tsx new file mode 100644 index 0000000..c3d9c8d --- /dev/null +++ b/webview/__tests__/components/atoms/ActionButton.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ActionButton } from "../../../components/atoms/ActionButton"; + +describe("ActionButton", () => { + // renders a button element + context("デフォルトの描画の場合", () => { + // renders a + ), + panel: ({ close }: { close: () => void }) => ( +
+ +
+ ), + }; + + // renders trigger + context("初期描画の場合", () => { + // renders trigger content + it("トリガーが描画されること", () => { + render(); + expect(screen.getByTestId("trigger")).toBeInTheDocument(); + }); + + // does not render panel + it("パネルが描画されないこと", () => { + render(); + expect(screen.queryByTestId("panel")).not.toBeInTheDocument(); + }); + + // trigger receives open=false + it("トリガーに open=false が渡されること", () => { + render(); + expect(screen.getByTestId("trigger")).toHaveTextContent("closed"); + }); + + // applies root class + it("root クラスを持つこと", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("root"); + }); + }); + + // clicking trigger opens panel + context("トリガーをクリックした場合", () => { + // shows panel + it("パネルが表示されること", () => { + render(); + fireEvent.click(screen.getByTestId("trigger")); + expect(screen.getByTestId("panel")).toBeInTheDocument(); + }); + + // trigger receives open=true + it("トリガーに open=true が渡されること", () => { + render(); + fireEvent.click(screen.getByTestId("trigger")); + expect(screen.getByTestId("trigger")).toHaveTextContent("open"); + }); + }); + + // toggle: clicking trigger again closes panel + context("トリガーを2回クリックした場合", () => { + // hides panel + it("パネルが閉じること", () => { + render(); + const trigger = screen.getByTestId("trigger"); + fireEvent.click(trigger); + expect(screen.getByTestId("panel")).toBeInTheDocument(); + fireEvent.click(trigger); + expect(screen.queryByTestId("panel")).not.toBeInTheDocument(); + }); + }); + + // close callback from panel + context("パネル内の close を呼んだ場合", () => { + // hides panel + it("パネルが閉じること", () => { + render(); + fireEvent.click(screen.getByTestId("trigger")); + fireEvent.click(screen.getByTestId("close-btn")); + expect(screen.queryByTestId("panel")).not.toBeInTheDocument(); + }); + }); + + // clicking outside closes panel + context("外側をクリックした場合", () => { + // hides panel + it("パネルが閉じること", () => { + render( +
+ +
+
, + ); + fireEvent.click(screen.getByTestId("trigger")); + expect(screen.getByTestId("panel")).toBeInTheDocument(); + fireEvent.mouseDown(screen.getByTestId("outside")); + expect(screen.queryByTestId("panel")).not.toBeInTheDocument(); + }); + }); + + // clicking inside panel does not close it + context("パネル内部をクリックした場合", () => { + // keeps panel open + it("パネルが開いたままであること", () => { + render(); + fireEvent.click(screen.getByTestId("trigger")); + fireEvent.mouseDown(screen.getByTestId("panel")); + expect(screen.getByTestId("panel")).toBeInTheDocument(); + }); + }); + + // className prop + context("className を指定した場合", () => { + // applies additional class + it("追加クラスが適用されること", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("root", "custom"); + }); + }); +}); diff --git a/webview/__tests__/components/atoms/StatusItem.test.tsx b/webview/__tests__/components/atoms/StatusItem.test.tsx new file mode 100644 index 0000000..324b4fd --- /dev/null +++ b/webview/__tests__/components/atoms/StatusItem.test.tsx @@ -0,0 +1,83 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatusItem } from "../../../components/atoms/StatusItem"; + +describe("StatusItem", () => { + // when rendered with basic props + context("基本のプロパティで描画した場合", () => { + // renders the indicator + it("インジケータを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".indicator")?.textContent).toBe("○"); + }); + + // renders the content + it("コンテンツを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".content")?.textContent).toBe("Task A"); + }); + + // does not have the done class + it("done クラスを持たないこと", () => { + const { container } = render(); + expect(container.querySelector(".root")?.classList.contains("done")).toBe(false); + }); + }); + + // when isDone is true + context("isDone が true の場合", () => { + // has the done class + it("done クラスを持つこと", () => { + const { container } = render(); + expect(container.querySelector(".root")?.classList.contains("done")).toBe(true); + }); + }); + + // when badge is provided with danger variant + context("badge が danger バリアントで指定された場合", () => { + // renders the badge label + it("バッジラベルを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".badge")?.textContent).toBe("high"); + }); + + // has the danger class on the badge + it("バッジに danger クラスを持つこと", () => { + const { container } = render( + , + ); + expect(container.querySelector(".badge")?.classList.contains("danger")).toBe(true); + }); + }); + + // when badge is provided with muted variant + context("badge が muted バリアントで指定された場合", () => { + // has the muted class on the badge + it("バッジに muted クラスを持つこと", () => { + const { container } = render( + , + ); + expect(container.querySelector(".badge")?.classList.contains("muted")).toBe(true); + }); + }); + + // when no badge is set + context("badge が未設定の場合", () => { + // does not render badge element + it("バッジ要素を表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".badge")).toBeNull(); + }); + }); + + // when className is provided + context("className が指定された場合", () => { + // applies the custom class + it("カスタムクラスが付与されること", () => { + const { container } = render(); + expect(container.querySelector(".root")?.classList.contains("custom")).toBe(true); + }); + }); +}); diff --git a/webview/__tests__/components/atoms/StreamingIndicator.test.tsx b/webview/__tests__/components/atoms/StreamingIndicator.test.tsx new file mode 100644 index 0000000..0b22ae2 --- /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 root container + it("root コンテナをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector("[data-testid='streaming-indicator']")).toBeInTheDocument(); + }); + + // renders exactly 3 dots + it("ドットを 3 つレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".dot")).toHaveLength(3); + }); + }); +}); diff --git a/webview/__tests__/components/atoms/icons.test.tsx b/webview/__tests__/components/atoms/icons.test.tsx new file mode 100644 index 0000000..e151806 --- /dev/null +++ b/webview/__tests__/components/atoms/icons.test.tsx @@ -0,0 +1,257 @@ +import { render } from "@testing-library/react"; +import type { FC } from "react"; +import { describe, expect, it } from "vitest"; +import type { IconProps } from "../../../components/atoms/icons"; +import { + AddIcon, + AgentIcon, + BackIcon, + CheckboxIcon, + ChevronRightIcon, + ClipIcon, + CloseIcon, + DeleteIcon, + DiffIcon, + EditActionIcon, + EditIcon, + ErrorCircleIcon, + ExternalLinkIcon, + EyeIcon, + EyeOffIcon, + FileIcon, + ForkIcon, + GearIcon, + InfoCircleIcon, + ListIcon, + PlusIcon, + ReadActionIcon, + RedoIcon, + RevertIcon, + RunActionIcon, + SearchActionIcon, + SendIcon, + ShareIcon, + SpinnerIcon, + StopIcon, + TerminalIcon, + ToolIcon, + UndoIcon, + UnshareIcon, + WriteActionIcon, +} from "../../../components/atoms/icons"; + +function describeIconComponent(Icon: FC, defaultW: number, defaultH: number) { + // when rendered with default props + context("デフォルトの描画の場合", () => { + // renders an element + it(" 要素をレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + // has aria-hidden attribute + it("aria-hidden 属性を持つこと", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + }); + + // has correct default width + it(`デフォルトの幅が ${defaultW} であること`, () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("width")).toBe(String(defaultW)); + }); + + // has correct default height + it(`デフォルトの高さが ${defaultH} であること`, () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("height")).toBe(String(defaultH)); + }); + }); + + // when custom props are passed + context("カスタム props を指定した場合", () => { + // accepts custom width + it("カスタム width が反映されること", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("width")).toBe("24"); + }); + + // accepts custom height + it("カスタム height が反映されること", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("height")).toBe("24"); + }); + + // passes className through + it("className が SVG 要素に反映されること", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveClass("custom-class"); + }); + + // spreads arbitrary SVG attributes + it("任意の SVG 属性がスプレッドされること", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveAttribute("data-testid", "icon"); + }); + }); +} + +describe("ListIcon", () => { + describeIconComponent(ListIcon, 16, 16); +}); + +describe("AddIcon", () => { + describeIconComponent(AddIcon, 16, 16); +}); + +describe("PlusIcon", () => { + describeIconComponent(PlusIcon, 12, 12); +}); + +describe("ChevronRightIcon", () => { + describeIconComponent(ChevronRightIcon, 12, 12); +}); + +describe("FileIcon", () => { + describeIconComponent(FileIcon, 12, 12); +}); + +describe("ClipIcon", () => { + describeIconComponent(ClipIcon, 14, 14); +}); + +describe("CloseIcon", () => { + describeIconComponent(CloseIcon, 14, 14); +}); + +describe("DeleteIcon", () => { + describeIconComponent(DeleteIcon, 14, 14); +}); + +describe("EditIcon", () => { + describeIconComponent(EditIcon, 14, 14); +}); + +describe("SendIcon", () => { + describeIconComponent(SendIcon, 16, 16); +}); + +describe("StopIcon", () => { + describeIconComponent(StopIcon, 16, 16); +}); + +describe("RevertIcon", () => { + describeIconComponent(RevertIcon, 12, 12); +}); + +describe("TerminalIcon", () => { + describeIconComponent(TerminalIcon, 16, 16); +}); + +describe("GearIcon", () => { + describeIconComponent(GearIcon, 16, 16); +}); + +describe("SpinnerIcon", () => { + describeIconComponent(SpinnerIcon, 16, 16); + + context("SpinnerIcon 固有の描画の場合", () => { + // uses fill='none' (stroke-based rendering) + it("fill が 'none' であること(ストローク描画)", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("fill")).toBe("none"); + }); + + // contains a circle element for the track + it("トラック用の circle 要素を含むこと", () => { + const { container } = render(); + expect(container.querySelector("circle")).toBeInTheDocument(); + }); + + // contains a path element for the spinner arc + it("スピナーアーク用の path 要素を含むこと", () => { + const { container } = render(); + expect(container.querySelector("path")).toBeInTheDocument(); + }); + }); +}); + +describe("ErrorCircleIcon", () => { + describeIconComponent(ErrorCircleIcon, 16, 16); +}); + +describe("InfoCircleIcon", () => { + describeIconComponent(InfoCircleIcon, 14, 14); +}); + +describe("CheckboxIcon", () => { + describeIconComponent(CheckboxIcon, 14, 14); +}); + +describe("ReadActionIcon", () => { + describeIconComponent(ReadActionIcon, 14, 14); +}); + +describe("EditActionIcon", () => { + describeIconComponent(EditActionIcon, 14, 14); +}); + +describe("WriteActionIcon", () => { + describeIconComponent(WriteActionIcon, 14, 14); +}); + +describe("RunActionIcon", () => { + describeIconComponent(RunActionIcon, 14, 14); +}); + +describe("SearchActionIcon", () => { + describeIconComponent(SearchActionIcon, 14, 14); +}); + +describe("ToolIcon", () => { + describeIconComponent(ToolIcon, 14, 14); +}); + +describe("EyeIcon", () => { + describeIconComponent(EyeIcon, 14, 14); +}); + +describe("EyeOffIcon", () => { + describeIconComponent(EyeOffIcon, 14, 14); +}); + +describe("DiffIcon", () => { + describeIconComponent(DiffIcon, 16, 16); +}); + +describe("ExternalLinkIcon", () => { + describeIconComponent(ExternalLinkIcon, 12, 12); +}); + +describe("ForkIcon", () => { + describeIconComponent(ForkIcon, 12, 12); +}); + +describe("BackIcon", () => { + describeIconComponent(BackIcon, 16, 16); +}); + +describe("ShareIcon", () => { + describeIconComponent(ShareIcon, 16, 16); +}); + +describe("UnshareIcon", () => { + describeIconComponent(UnshareIcon, 16, 16); +}); + +describe("AgentIcon", () => { + describeIconComponent(AgentIcon, 16, 16); +}); + +describe("UndoIcon", () => { + describeIconComponent(UndoIcon, 16, 16); +}); + +describe("RedoIcon", () => { + describeIconComponent(RedoIcon, 16, 16); +}); diff --git a/webview/__tests__/components/molecules/AgentPopup.test.tsx b/webview/__tests__/components/molecules/AgentPopup.test.tsx new file mode 100644 index 0000000..28a3e15 --- /dev/null +++ b/webview/__tests__/components/molecules/AgentPopup.test.tsx @@ -0,0 +1,69 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AgentPopup } from "../../../components/molecules/AgentPopup"; + +function createAgent(name: string, description?: string) { + return { + name, + description, + mode: "subagent" as const, + builtIn: true, + permission: { edit: "ask" as const, bash: {} }, + tools: {}, + options: {}, + }; +} + +describe("AgentPopup", () => { + // when rendered with agents + context("エージェント一覧がある場合", () => { + const agents = [createAgent("coder", "Coding agent"), createAgent("researcher", "Research agent")]; + + // renders agent names + it("エージェント名を表示すること", () => { + const { container } = render( + , + ); + const titles = container.querySelectorAll(".title"); + expect(titles[0]?.textContent).toBe("coder"); + expect(titles[1]?.textContent).toBe("researcher"); + }); + + // renders agent descriptions + it("エージェントの説明を表示すること", () => { + const { container } = render( + , + ); + const descriptions = container.querySelectorAll(".description"); + expect(descriptions[0]?.textContent).toBe("Coding agent"); + }); + + // calls onSelectAgent when clicked + it("クリックで onSelectAgent を呼ぶこと", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + , + ); + const items = container.querySelectorAll(".root > div"); + await user.click(items[0]!); + expect(onSelect).toHaveBeenCalledWith(agents[0]); + }); + }); + + // when focusedIndex highlights a specific agent + context("focusedIndex が指定された場合", () => { + const agents = [createAgent("coder", "Coding agent"), createAgent("researcher", "Research agent")]; + + // applies data-focused to the correct item + it("対応するアイテムに data-focused 属性が付与されること", () => { + const { container } = render( + , + ); + const items = container.querySelectorAll(".root > div"); + expect(items[0]?.getAttribute("data-focused")).toBe("true"); + expect(items[1]?.hasAttribute("data-focused")).toBe(false); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/DiffView.test.tsx b/webview/__tests__/components/molecules/DiffView.test.tsx new file mode 100644 index 0000000..d8b9dc7 --- /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(".statAdd")).toBeInTheDocument(); + }); + + // renders remove count + it("削除行数を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".statRemove")).toBeInTheDocument(); + }); + + // renders diff lines + it("差分行をレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".line").length).toBeGreaterThan(0); + }); + }); + + // when strings are identical + context("文字列が同一の場合", () => { + // does not show add stat + it("追加行数を表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".statAdd")).not.toBeInTheDocument(); + }); + + // does not show remove stat + it("削除行数を表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".statRemove")).not.toBeInTheDocument(); + }); + }); + + // when only additions exist + context("追加行のみの場合", () => { + // renders add stat + it("追加行数のみを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".statAdd")).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..1985c43 --- /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(".title")).toBeInTheDocument(); + }); + + // renders the description + it("説明文を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".description")).toBeInTheDocument(); + }); + + // renders the new chat button + it("新規チャットボタンを表示すること", () => { + const { container } = render(); + expect(container.querySelector("button")).toBeInTheDocument(); + }); + }); + + // when new chat button is clicked + context("新規チャットボタンをクリックした場合", () => { + // calls onNewSession + it("onNewSession が呼ばれること", () => { + const onNewSession = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector("button")!); + 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..275762b --- /dev/null +++ b/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx @@ -0,0 +1,97 @@ +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(".outlined")).toBeInTheDocument(); + }); + }); + + // when clip button is clicked + context("クリップボタンをクリックした場合", () => { + // calls onClipClick + it("onClipClick が呼ばれること", () => { + const onClipClick = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector(".outlined")!); + expect(onClipClick).toHaveBeenCalledOnce(); + }); + }); + + // when files are attached + context("ファイルが添付されている場合", () => { + // renders attached file chips + it("添付ファイルチップをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".chip")).toHaveLength(2); + }); + + // calls onRemoveFile when remove button is clicked + it("削除ボタンクリックで onRemoveFile が呼ばれること", () => { + const onRemoveFile = vi.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector(".chipRemove")!); + 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(".fileButton")).toBeInTheDocument(); + }); + }); + + // when activeEditorFile is already attached + context("アクティブエディタファイルが既に添付済みの場合", () => { + // does not render quick-add button + it("quick-add ボタンをレンダリングしないこと", () => { + const { container } = render( + , + ); + expect(container.querySelector(".fileButton")).not.toBeInTheDocument(); + }); + }); + + // when file picker is open + context("ファイルピッカーが開いている場合", () => { + // renders the dropdown + it("ドロップダウンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".pickerDropdown")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/FileChangesHeader.test.tsx b/webview/__tests__/components/molecules/FileChangesHeader.test.tsx new file mode 100644 index 0000000..1f7c079 --- /dev/null +++ b/webview/__tests__/components/molecules/FileChangesHeader.test.tsx @@ -0,0 +1,262 @@ +import type { FileDiff } from "@opencode-ai/sdk"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FileChangesHeader } from "../../../components/molecules/FileChangesHeader"; + +function createFileDiff(overrides: Partial = {}): FileDiff { + return { + file: "src/index.ts", + before: "const a = 1;", + after: "const a = 2;", + additions: 1, + deletions: 1, + ...overrides, + }; +} + +describe("FileChangesHeader", () => { + const defaultProps = { + diffs: [createFileDiff()], + onOpenDiffEditor: vi.fn(), + }; + + // default rendering + context("デフォルトの描画の場合", () => { + // renders the header bar + it("ヘッダーバーをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".bar")).toBeInTheDocument(); + }); + + // shows file count + it("ファイル数を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".count")?.textContent).toBe("1"); + }); + + // shows additions and deletions stats + it("追加・削除の統計を表示すること", () => { + const { container } = render(); + const addStat = container.querySelector(".statAdd"); + const removeStat = container.querySelector(".statRemove"); + expect(addStat?.textContent).toBe("+1"); + expect(removeStat?.textContent).toBe("−1"); + }); + + // list is collapsed by default + it("リストはデフォルトで折りたたまれていること", () => { + const { container } = render(); + expect(container.querySelector(".list")).not.toBeInTheDocument(); + }); + }); + + // when header is clicked + context("ヘッダーをクリックした場合", () => { + // expands the file list + it("ファイルリストを展開すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelector(".list")).toBeInTheDocument(); + }); + + // shows file name + it("ファイル名を表示すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const fileName = container.querySelector(".fileName"); + expect(fileName?.textContent).toBe("index.ts"); + }); + + // shows status badge M for modified file + it("変更ファイルにステータスバッジ M を表示すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const badge = container.querySelector(".statusBadge"); + expect(badge?.textContent).toBe("M"); + }); + }); + + // added file + context("新規追加ファイルの場合", () => { + // renders status badge A + it("ステータスバッジ A を表示すること", () => { + const diffs = [createFileDiff({ before: "", after: "new content", file: "new.ts" })]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const badge = container.querySelector(".statusBadge"); + expect(badge?.textContent).toBe("A"); + }); + }); + + // deleted file + context("削除ファイルの場合", () => { + // renders status badge D + it("ステータスバッジ D を表示すること", () => { + const diffs = [createFileDiff({ before: "old content", after: "", file: "deleted.ts" })]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const badge = container.querySelector(".statusBadge"); + expect(badge?.textContent).toBe("D"); + }); + }); + + // expand inline diff + context("ファイルアイテムをクリックした場合", () => { + // shows inline diff view + it("インライン差分ビューを表示すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const fileHeader = container.querySelector(".fileHeader"); + fireEvent.click(fileHeader!); + expect(screen.getByTestId("diff-view")).toBeTruthy(); + }); + }); + + // multiple files + context("複数ファイルがある場合", () => { + // renders all files + it("全ファイルを表示すること", () => { + const diffs = [ + createFileDiff({ file: "src/a.ts" }), + createFileDiff({ file: "src/b.ts" }), + createFileDiff({ file: "src/c.ts" }), + ]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const fileItems = container.querySelectorAll(".fileItem"); + expect(fileItems.length).toBe(3); + }); + + // shows correct total stats in header + it("ヘッダーに合計の追加・削除数を表示すること", () => { + const diffs = [ + createFileDiff({ file: "a.ts", additions: 3, deletions: 1 }), + createFileDiff({ file: "b.ts", additions: 5, deletions: 2 }), + ]; + const { container } = render(); + const stats = container.querySelector(".stats"); + expect(stats?.textContent).toContain("+8"); + expect(stats?.textContent).toContain("−3"); + }); + }); + + // directory path display + context("ディレクトリパスがある場合", () => { + // shows directory path + it("ディレクトリパスを表示すること", () => { + const diffs = [createFileDiff({ file: "src/components/Button.tsx" })]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const filePath = container.querySelector(".filePath"); + expect(filePath?.textContent).toBe("src/components"); + }); + }); + + // open diff editor + context("外部リンクボタンをクリックした場合", () => { + // calls onOpenDiffEditor + it("onOpenDiffEditor が呼ばれること", () => { + const onOpenDiffEditor = vi.fn(); + const diff = createFileDiff({ file: "src/app.ts", before: "old", after: "new" }); + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const openButton = container.querySelector(".openButton"); + fireEvent.click(openButton!); + expect(onOpenDiffEditor).toHaveBeenCalledWith("src/app.ts", "old", "new"); + }); + + // does not toggle file expansion + it("ファイルの展開がトグルされないこと", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const openButton = container.querySelector(".openButton"); + fireEvent.click(openButton!); + expect(container.querySelector(".diffBody")).not.toBeInTheDocument(); + }); + }); + + // toggle collapse + context("ヘッダーを2回クリックした場合", () => { + // collapses the file list + it("ファイルリストが折りたたまれること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelector(".list")).toBeInTheDocument(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelector(".list")).not.toBeInTheDocument(); + }); + }); + + // chevron rotation + context("展開時のシェブロンの場合", () => { + // applies expanded class to chevron + it("chevron に expanded クラスが付与されること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelector(".chevron")).toHaveClass("expanded"); + }); + }); + + // no directory path for root-level file + context("ルートレベルのファイルの場合", () => { + // does not show directory path + it("ディレクトリパスが表示されないこと", () => { + const diffs = [createFileDiff({ file: "README.md" })]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelector(".filePath")).not.toBeInTheDocument(); + }); + }); + + // zero additions/deletions + context("追加・削除が 0 の場合", () => { + // does not show addition stat + it("追加統計が表示されないこと", () => { + const diffs = [createFileDiff({ additions: 0, deletions: 3 })]; + const { container } = render(); + expect(container.querySelector(".statAdd")).not.toBeInTheDocument(); + }); + + // does not show deletion stat + it("削除統計が表示されないこと", () => { + const diffs = [createFileDiff({ additions: 5, deletions: 0 })]; + const { container } = render(); + expect(container.querySelector(".statRemove")).not.toBeInTheDocument(); + }); + }); + + // file item inline diff toggle + context("ファイルアイテムを2回クリックした場合", () => { + // collapses the inline diff + it("インライン差分が折りたたまれること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const fileHeader = container.querySelector(".fileHeader")!; + fireEvent.click(fileHeader); + expect(container.querySelector(".diffBody")).toBeInTheDocument(); + fireEvent.click(fileHeader); + expect(container.querySelector(".diffBody")).not.toBeInTheDocument(); + }); + }); + + // file item per-file stats + context("ファイルアイテムの個別統計の場合", () => { + // shows per-file additions + it("ファイルごとの追加数を表示すること", () => { + const diffs = [createFileDiff({ file: "a.ts", additions: 7, deletions: 2 })]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const fileStats = container.querySelector(".fileStats"); + expect(fileStats?.querySelector(".statAdd")?.textContent).toBe("+7"); + }); + + // shows per-file deletions + it("ファイルごとの削除数を表示すること", () => { + const diffs = [createFileDiff({ file: "a.ts", additions: 7, deletions: 2 })]; + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + const fileStats = container.querySelector(".fileStats"); + expect(fileStats?.querySelector(".statRemove")?.textContent).toBe("−2"); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/FileCreateView.test.tsx b/webview/__tests__/components/molecules/FileCreateView.test.tsx new file mode 100644 index 0000000..6505013 --- /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(".lineAdd")).toHaveLength(3); + }); + + // shows add lines count in stats + it("追加行数を stats に表示すること", () => { + const content = "line1\nline2"; + const { container } = render(); + expect(container.querySelector(".statAdd")).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(".lineAdd")).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..7bd97d1 --- /dev/null +++ b/webview/__tests__/components/molecules/HashFilePopup.test.tsx @@ -0,0 +1,58 @@ +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("[data-testid='hash-popup'] > div")).toHaveLength(2); + }); + + // calls onAddFile when item is clicked + it("アイテムクリックで onAddFile が呼ばれること", () => { + const onAddFile = vi.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector("[data-testid='hash-popup'] > div")!); + expect(onAddFile).toHaveBeenCalledWith(file1); + }); + }); + + // when no files match + context("ファイル候補がない場合", () => { + // renders empty message + it("空メッセージを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".empty")).toBeInTheDocument(); + }); + }); + + // when focusedIndex highlights a specific item + context("focusedIndex が指定された場合", () => { + // applies data-focused to the correct item + it("対応するアイテムに data-focused 属性が付与されること", () => { + const { container } = render( + , + ); + const items = container.querySelectorAll("[data-testid='hash-popup'] > div"); + expect(items[0]?.hasAttribute("data-focused")).toBe(false); + expect(items[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/ModelSelector.test.tsx b/webview/__tests__/components/molecules/ModelSelector.test.tsx new file mode 100644 index 0000000..2c6085a --- /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(".button")).toBeInTheDocument(); + }); + + // shows selected model name + it("選択中のモデル名を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".label")?.textContent).toBe("GPT-4"); + }); + }); + + // when button is clicked + context("ボタンをクリックした場合", () => { + // opens the model panel + it("モデルパネルを開くこと", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".button")!); + expect(container.querySelector(".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(".button")!); + fireEvent.click(container.querySelector(".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(".label")?.textContent).toBeTruthy(); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/ShellResultView.test.tsx b/webview/__tests__/components/molecules/ShellResultView.test.tsx new file mode 100644 index 0000000..945278e --- /dev/null +++ b/webview/__tests__/components/molecules/ShellResultView.test.tsx @@ -0,0 +1,202 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ShellResultView } from "../../../components/molecules/ShellResultView"; +import { createToolPart } from "../../factories"; + +describe("ShellResultView", () => { + // --- Completed command --- + context("完了したコマンドを表示する場合", () => { + // renders Shell header + it("Shell ヘッダーを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + + // renders $ prompt with command + it("$ プロンプトとコマンドを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls -la", input: { command: "ls -la" }, output: "total 0" }, + } as any), + ]; + render(); + expect(screen.getByText("$")).toBeInTheDocument(); + expect(screen.getByText("ls -la")).toBeInTheDocument(); + }); + + // renders output text + it("出力テキストを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "echo", input: { command: "echo hello" }, output: "hello" }, + } as any), + ]; + render(); + expect(screen.getByText("hello")).toBeInTheDocument(); + }); + + // is expanded by default + it("デフォルトで展開状態であること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + expect(screen.getByText("file.txt")).toBeInTheDocument(); + }); + }); + + // --- Collapse/expand toggle --- + context("ヘッダーをクリックした場合", () => { + // hides output when collapsed + it("出力が非表示になること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + fireEvent.click(screen.getByText("Shell")); + expect(screen.queryByText("file.txt")).not.toBeInTheDocument(); + }); + + // shows output when expanded again + it("再度クリックで出力が表示されること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + fireEvent.click(screen.getByText("Shell")); + fireEvent.click(screen.getByText("Shell")); + expect(screen.getByText("file.txt")).toBeInTheDocument(); + }); + }); + + // --- Running command --- + context("実行中のコマンドを表示する場合", () => { + // renders spinner + it("スピナーを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "running", title: "sleep", input: { command: "sleep 10" } }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='spinner']")).toBeInTheDocument(); + }); + + // does not render output + it("出力を表示しないこと", () => { + const parts = [ + createToolPart("bash", { + state: { status: "running", title: "sleep", input: { command: "sleep 10" } }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='output']")).not.toBeInTheDocument(); + }); + }); + + // --- Pending command --- + context("pending 状態のコマンドを表示する場合", () => { + // renders spinner for pending status + it("スピナーを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "pending" }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='spinner']")).toBeInTheDocument(); + }); + }); + + // --- Error command --- + context("エラーのコマンドを表示する場合", () => { + // renders error output + it("エラー出力を表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "error", title: "bad", input: { command: "bad-cmd" }, error: "command not found" }, + } as any), + ]; + render(); + expect(screen.getByText("command not found")).toBeInTheDocument(); + }); + + // applies error styling + it("エラー用のスタイルが適用されること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "error", title: "bad", input: { command: "bad-cmd" }, error: "command not found" }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='outputError']")).toBeInTheDocument(); + }); + }); + + // --- Multiple entries --- + context("複数のコマンド結果がある場合", () => { + // renders all entries + it("すべてのコマンドエントリを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file1.txt" }, + } as any), + createToolPart("bash", { + state: { status: "completed", title: "pwd", input: { command: "pwd" }, output: "/home" }, + } as any), + ]; + render(); + expect(screen.getByText("file1.txt")).toBeInTheDocument(); + expect(screen.getByText("/home")).toBeInTheDocument(); + }); + + // renders multiple $ prompts + it("複数の $ プロンプトを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file1.txt" }, + } as any), + createToolPart("bash", { + state: { status: "completed", title: "pwd", input: { command: "pwd" }, output: "/home" }, + } as any), + ]; + render(); + expect(screen.getAllByText("$")).toHaveLength(2); + }); + }); + + // --- Empty parts --- + context("パーツが空の場合", () => { + // renders header without error + it("ヘッダーのみ表示してエラーにならないこと", () => { + render(); + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + }); + + // --- Non-tool parts are filtered --- + context("tool 以外のパーツが含まれる場合", () => { + // filters out non-tool parts + it("tool 以外のパーツが除外されること", () => { + const parts = [ + { id: "p1", type: "text", text: "ignored", messageID: "m1" } as any, + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "ok" }, + } as any), + ]; + render(); + expect(screen.getAllByText("$")).toHaveLength(1); + }); + }); +}); diff --git a/webview/__tests__/components/molecules/TextPartView.test.tsx b/webview/__tests__/components/molecules/TextPartView.test.tsx new file mode 100644 index 0000000..12339ae --- /dev/null +++ b/webview/__tests__/components/molecules/TextPartView.test.tsx @@ -0,0 +1,71 @@ +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"); + }); + }); + + // XSS protection via DOMPurify + context("悪意のある HTML を含むテキストの場合", () => { + // strips '); + const { container } = render(); + expect(container.innerHTML).not.toContain("onerror"); + }); + + // strips javascript: URLs + it("javascript: URL が除去されること", () => { + const part = createTextPart('click'); + const { container } = render(); + expect(container.innerHTML).not.toContain("javascript:"); + }); + + // strips '); + const { container } = render(); + expect(container.querySelector("iframe")).toBeNull(); + }); + + // preserves safe HTML content + it("安全な HTML コンテンツは保持されること", () => { + const part = createTextPart("safe bold text"); + const { container } = render(); + expect(container.textContent).toContain("safe bold 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..19d4afa --- /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(".bar")).toBeInTheDocument(); + }); + + // shows completion count + it("完了数を表示すること", () => { + const { container } = render(); + expect(container.querySelector(".count")?.textContent).toBe("1/3"); + }); + + // renders progress bar + it("プログレスバーをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".progress")).toBeInTheDocument(); + }); + }); + + // when header is clicked + context("ヘッダーをクリックした場合", () => { + // expands the todo list + it("todo リストを展開すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelector(".list")).toBeInTheDocument(); + }); + + // shows all todo items + it("全ての todo アイテムを表示すること", () => { + const { container } = render(); + fireEvent.click(container.querySelector(".bar")!); + expect(container.querySelectorAll("li")).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(".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..a7dd911 --- /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("li")).toHaveLength(3); + }); + + // shows completion summary + it("完了数サマリーを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".summary")).toBeInTheDocument(); + }); + + // marks completed items with check mark + it("完了アイテムにチェックマークを表示すること", () => { + const { container } = render(); + const checks = container.querySelectorAll(".indicator"); + expect(checks[0]?.textContent).toBe("✓"); + }); + + // marks incomplete items with circle + it("未完了アイテムに丸を表示すること", () => { + const { container } = render(); + const checks = container.querySelectorAll(".indicator"); + expect(checks[1]?.textContent).toBe("○"); + }); + }); + + // when a todo has priority + context("todo に priority がある場合", () => { + // renders priority badge + it("priority バッジを表示すること", () => { + const { container } = render(); + expect(container.querySelectorAll(".badge").length).toBeGreaterThan(0); + }); + }); + + // when todos list is empty + context("todo リストが空の場合", () => { + // renders no items + it("アイテムをレンダリングしないこと", () => { + const { container } = render(); + expect(container.querySelectorAll("li")).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..dbf7626 --- /dev/null +++ b/webview/__tests__/components/organisms/ChatHeader.test.tsx @@ -0,0 +1,192 @@ +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(), + onShareSession: vi.fn(), + onUnshareSession: vi.fn(), + canUndo: false, + canRedo: false, + isBusy: false, + }; + + // when rendered with an active session + context("アクティブセッションがある場合", () => { + // renders the session title + it("セッションタイトルを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".title")?.textContent).toBe("Test Session"); + }); + + // renders the session list button + it("セッションリストボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll("button").length).toBeGreaterThan(0); + }); + + // renders the share button + it("共有ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Share session")).toBeInTheDocument(); + }); + }); + + // when new session button is clicked + context("新規セッションボタンをクリックした場合", () => { + // calls onNewSession + it("onNewSession が呼ばれること", () => { + const onNewSession = vi.fn(); + const { container } = render(); + const buttons = container.querySelectorAll("button"); + // New chat button is the last icon-button + 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("button"); + fireEvent.click(buttons[0]!); + expect(onToggleSessionList).toHaveBeenCalledOnce(); + }); + }); + + // when activeSession is null + context("アクティブセッションが null の場合", () => { + // renders fallback title + it("フォールバックタイトルを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".title")?.textContent).toBeTruthy(); + }); + + // does not render share button + it("共有ボタンを表示しないこと", () => { + const { queryByTitle } = render(); + expect(queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // when share button is clicked (not shared) + context("未共有セッションで共有ボタンをクリックした場合", () => { + // calls onShareSession + it("onShareSession が呼ばれること", () => { + const onShareSession = vi.fn(); + const { getByTitle } = render(); + fireEvent.click(getByTitle("Share session")); + expect(onShareSession).toHaveBeenCalledOnce(); + }); + }); + + // when session is shared + context("共有中のセッションの場合", () => { + const sharedSession = createSession({ + title: "Shared Session", + share: { url: "https://share.example.com/abc" }, + }); + + // renders unshare button + it("共有解除ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Unshare session")).toBeInTheDocument(); + }); + + // calls onUnshareSession when clicked + it("クリック時に onUnshareSession が呼ばれること", () => { + const onUnshareSession = vi.fn(); + const { getByTitle } = render( + , + ); + fireEvent.click(getByTitle("Unshare session")); + expect(onUnshareSession).toHaveBeenCalledOnce(); + }); + }); + + // when navigating child session + context("子セッション閲覧中の場合", () => { + // does not render share button + it("共有ボタンを表示しないこと", () => { + const { queryByTitle } = render( {}} />); + expect(queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // when onShareSession is not provided (empty session) + context("onShareSession が未指定の場合(空セッション)", () => { + // does not render share button + it("共有ボタンを表示しないこと", () => { + const { queryByTitle } = render(); + expect(queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // Undo/Redo buttons + context("Undo/Redo ボタンの場合", () => { + // renders undo button + it("Undo ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Undo")).toBeInTheDocument(); + }); + + // renders redo button + it("Redo ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Redo")).toBeInTheDocument(); + }); + + // disables undo when canUndo is false + it("canUndo=false のとき Undo ボタンが disabled になること", () => { + const { getByTitle } = render(); + expect((getByTitle("Undo") as HTMLButtonElement).disabled).toBe(true); + }); + + // disables redo when canRedo is false + it("canRedo=false のとき Redo ボタンが disabled になること", () => { + const { getByTitle } = render(); + expect((getByTitle("Redo") as HTMLButtonElement).disabled).toBe(true); + }); + + // disables both when busy + it("isBusy=true のとき Undo ボタンが disabled になること", () => { + const { getByTitle } = render(); + expect((getByTitle("Undo") as HTMLButtonElement).disabled).toBe(true); + }); + + // calls onUndo when clicked + it("クリック時に onUndo が呼ばれること", () => { + const onUndo = vi.fn(); + const { getByTitle } = render(); + fireEvent.click(getByTitle("Undo")); + expect(onUndo).toHaveBeenCalledOnce(); + }); + + // calls onRedo when clicked + it("クリック時に onRedo が呼ばれること", () => { + const onRedo = vi.fn(); + const { getByTitle } = render(); + fireEvent.click(getByTitle("Redo")); + expect(onRedo).toHaveBeenCalledOnce(); + }); + + // does not render undo/redo when in child session + it("子セッション閲覧中は Undo/Redo ボタンを表示しないこと", () => { + const { queryByTitle } = render( {}} canUndo canRedo />); + expect(queryByTitle("Undo")).not.toBeInTheDocument(); + }); + + // does not render undo/redo when no active session + it("アクティブセッションがない場合は Undo/Redo ボタンを表示しないこと", () => { + const { queryByTitle } = render(); + expect(queryByTitle("Undo")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/MessageItem.test.tsx b/webview/__tests__/components/organisms/MessageItem.test.tsx new file mode 100644 index 0000000..3cdc4a7 --- /dev/null +++ b/webview/__tests__/components/organisms/MessageItem.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { MessageWithParts } from "../../../App"; +import { MessageItem } from "../../../components/organisms/MessageItem"; +import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; +import { createMessage, createTextPart, createToolPart } from "../../factories"; + +/** AppContext 必須の値を最小限で提供するラッパー */ +function createContextWrapper(overrides: Partial = {}) { + const contextValue = { + isShellMessage: () => false, + ...overrides, + } as unknown as AppContextValue; + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("MessageItem", () => { + const wrapper = createContextWrapper(); + 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(, { wrapper }); + expect(container.querySelector(".user")).toBeInTheDocument(); + }); + + // renders user text + it("ユーザーテキストを表示すること", () => { + const { container } = render(, { wrapper }); + expect(container.querySelector(".content")?.textContent).toBe("Hello"); + }); + + // shows edit icon + it("編集アイコンを表示すること", () => { + const { container } = render(, { wrapper }); + expect(container.querySelector(".editIcon")).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(, { wrapper }); + fireEvent.click(container.querySelector(".userBubble")!); + expect(container.querySelector(".editTextarea")).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(, { wrapper }); + expect(container.querySelector(".assistant")).toBeInTheDocument(); + }); + + // renders text and tool parts + it("テキストとツールパートをレンダリングすること", () => { + const { container } = render(, { wrapper }); + expect(container.querySelector(".root")).toBeInTheDocument(); + }); + }); + + // when rendered with a shell assistant message + context("シェルコマンド結果のアシスタントメッセージの場合", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-msg" }); + const shellMsg: MessageWithParts = { + info: createMessage({ id: "shell-msg", role: "assistant" }), + parts: [ + createTextPart("The following tool was executed by the user"), + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ], + }; + + // renders ShellResultView instead of ToolPartView + it("ShellResultView をレンダリングすること", () => { + render(, { wrapper: shellWrapper }); + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + + // does not render the synthetic text part + it("テキストパートを表示しないこと", () => { + render(, { wrapper: shellWrapper }); + expect(screen.queryByText("The following tool was executed by the user")).not.toBeInTheDocument(); + }); + }); + + // when rendered with a shell user message + context("シェルコマンドのユーザーメッセージの場合", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-user" }); + const shellUserMsg: MessageWithParts = { + info: createMessage({ id: "shell-user", role: "user" }), + parts: [createTextPart("!ls -la")], + }; + + // hides user bubble + it("ユーザー吹き出しが非表示であること", () => { + const { container } = render(, { wrapper: shellWrapper }); + expect(container.querySelector("[class*='userBubble']")).not.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..b5a3c2a --- /dev/null +++ b/webview/__tests__/components/organisms/MessagesArea.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { MessageWithParts } from "../../../App"; +import { MessagesArea } from "../../../components/organisms/MessagesArea"; +import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; +import { createMessage, createTextPart } from "../../factories"; + +/** AppContext 必須の値を最小限で提供するラッパー */ +function createContextWrapper() { + const contextValue = { + isShellMessage: () => false, + } as unknown as AppContextValue; + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +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(), + onForkFromCheckpoint: vi.fn(), +}; + +describe("MessagesArea", () => { + const wrapper = createContextWrapper(); + + // when rendered with messages + context("メッセージがある場合", () => { + // renders message items + it("メッセージアイテムをレンダリングすること", () => { + const { container } = render(, { wrapper }); + expect(container.querySelectorAll(".message").length).toBeGreaterThan(0); + }); + }); + + // when session is busy + context("セッションが busy の場合", () => { + // renders streaming indicator + it("StreamingIndicator をレンダリングすること", () => { + const { container } = render(, { wrapper }); + expect(container.querySelector("[data-testid='streaming-indicator']")).toBeInTheDocument(); + }); + }); + + // when session is not busy + context("セッションが busy でない場合", () => { + // does not render streaming indicator + it("StreamingIndicator をレンダリングしないこと", () => { + const { container } = render(, { wrapper }); + expect(container.querySelector("[data-testid='streaming-indicator']")).not.toBeInTheDocument(); + }); + }); + + // when messages have checkpoint dividers + context("アシスタント→ユーザーの連続メッセージの場合", () => { + const msgs: MessageWithParts[] = [ + { info: createMessage({ role: "assistant", id: "ast-1" }), parts: [createTextPart("Reply")] }, + { info: createMessage({ role: "user", id: "usr-1" }), parts: [createTextPart("Follow up")] }, + ]; + + // renders checkpoint divider + it("チェックポイント区切り線をレンダリングすること", () => { + const { container } = render(, { wrapper }); + expect(container.querySelector(".checkpointDivider")).toBeInTheDocument(); + }); + + // renders fork button in checkpoint divider + it("Fork ボタンをレンダリングすること", () => { + render(, { wrapper }); + expect(screen.getByText("Fork from here")).toBeInTheDocument(); + }); + + // renders retry button in checkpoint divider + it("Retry ボタンをレンダリングすること", () => { + render(, { wrapper }); + expect(screen.getByText("Retry from here")).toBeInTheDocument(); + }); + + // calls onForkFromCheckpoint with next user message ID when fork button is clicked + it("Fork ボタンクリック時に onForkFromCheckpoint を次のユーザーメッセージ ID で呼び出すこと", async () => { + const onFork = vi.fn(); + render(, { wrapper }); + const forkButton = screen.getByText("Fork from here").closest("button")!; + await userEvent.click(forkButton); + expect(onFork).toHaveBeenCalledWith("usr-1"); + }); + + // calls onRevertToCheckpoint when retry button is clicked + it("Retry ボタンクリック時に onRevertToCheckpoint を呼び出すこと", async () => { + const onRevert = vi.fn(); + render(, { wrapper }); + const retryButton = screen.getByText("Retry from here").closest("button")!; + await userEvent.click(retryButton); + expect(onRevert).toHaveBeenCalledWith("usr-1", "Follow up"); + }); + }); + + // when there are no checkpoint dividers (user only, assistant only) + context("チェックポイントが存在しない場合", () => { + // does not render fork button + it("Fork ボタンをレンダリングしないこと", () => { + const msgs: MessageWithParts[] = [{ info: createMessage({ role: "user" }), parts: [createTextPart("Hello")] }]; + render(, { wrapper }); + expect(screen.queryByText("Fork from here")).not.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..87c925d --- /dev/null +++ b/webview/__tests__/components/organisms/PermissionView.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PermissionView } from "../../../components/organisms/PermissionView"; +import { postMessage } from "../../../vscode-api"; +import { createPermission } from "../../factories"; + +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(".title")?.textContent).toBe("Allow file write?"); + }); + + // renders three action buttons + it("3 つのアクションボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelectorAll(".actions button")).toHaveLength(3); + }); + }); + + // when Allow button is clicked + context("Allow ボタンをクリックした場合", () => { + // sends always response + it("always レスポンスを送信すること", () => { + const { container } = render(); + const buttons = container.querySelectorAll(".actions button"); + 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(".actions button"); + 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(".actions button"); + 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..23d0aa6 --- /dev/null +++ b/webview/__tests__/components/organisms/SessionList.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { formatRelativeTime, SessionList } from "../../../components/organisms/SessionList"; +import { en } from "../../../locales/en"; +import { ja } from "../../../locales/ja"; +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(".item")).toHaveLength(2); + }); + + // highlights the active session + it("アクティブセッションをハイライトすること", () => { + const { container } = render(); + expect(container.querySelector(".item.active")).toBeInTheDocument(); + }); + }); + + // when a session is clicked + context("セッションをクリックした場合", () => { + // calls onSelect + it("onSelect が呼ばれること", () => { + const onSelect = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelectorAll(".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(".itemDelete")!); + expect(onDelete).toHaveBeenCalledWith("s1"); + }); + }); + + // when there are no sessions + context("セッションがない場合", () => { + // renders empty message + it("空メッセージを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".item")).not.toBeInTheDocument(); + }); + }); +}); + +describe("formatRelativeTime", () => { + // when timestamp is less than 60 seconds ago + context("60秒未満前の場合", () => { + // returns "now" in English + it('英語で "now" を返すこと', () => { + expect(formatRelativeTime(Date.now() - 30_000, en)).toBe("now"); + }); + + // returns "今" in Japanese + it('日本語で "今" を返すこと', () => { + expect(formatRelativeTime(Date.now() - 30_000, ja)).toBe("今"); + }); + }); + + // when timestamp is minutes ago + context("数分前の場合", () => { + // returns minutes in English format + it('英語で "5m" 形式を返すこと', () => { + expect(formatRelativeTime(Date.now() - 5 * 60_000, en)).toBe("5m"); + }); + + // returns minutes in Japanese format + it('日本語で "5分" 形式を返すこと', () => { + expect(formatRelativeTime(Date.now() - 5 * 60_000, ja)).toBe("5分"); + }); + }); + + // when timestamp is hours ago + context("数時間前の場合", () => { + // returns hours in English format + it('英語で "3h" 形式を返すこと', () => { + expect(formatRelativeTime(Date.now() - 3 * 3_600_000, en)).toBe("3h"); + }); + + // returns hours in Japanese format + it('日本語で "3時間" 形式を返すこと', () => { + expect(formatRelativeTime(Date.now() - 3 * 3_600_000, ja)).toBe("3時間"); + }); + }); + + // when timestamp is days ago + context("数日前の場合", () => { + // returns days in English format + it('英語で "2d" 形式を返すこと', () => { + expect(formatRelativeTime(Date.now() - 2 * 86_400_000, en)).toBe("2d"); + }); + + // returns days in Japanese format + it('日本語で "2日" 形式を返すこと', () => { + expect(formatRelativeTime(Date.now() - 2 * 86_400_000, ja)).toBe("2日"); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/SubtaskPartView.test.tsx b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx new file mode 100644 index 0000000..e92d98e --- /dev/null +++ b/webview/__tests__/components/organisms/SubtaskPartView.test.tsx @@ -0,0 +1,343 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { findMatchingChild, isTaskToolPart, SubtaskPartView } from "../../../components/organisms/SubtaskPartView"; +import { createSession, createSubtaskPart, createTaskToolPart } from "../../factories"; + +const defaultProps = { + part: createSubtaskPart("coder", "Implement feature X"), + childSessions: [] as ReturnType[], + onNavigateToChild: vi.fn(), +}; + +describe("SubtaskPartView", () => { + // when rendered with a subtask part + context("subtask パートを描画した場合", () => { + // renders the header + it("ヘッダーをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".header")).toBeInTheDocument(); + }); + + // renders the agent icon + it("エージェントアイコンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".icon")).toBeInTheDocument(); + }); + + // renders the action label "Agent" + it("Agent ラベルをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".action")?.textContent).toBe("Agent"); + }); + + // renders agent name and description in title + it("エージェント名と説明をタイトルに表示すること", () => { + const { container } = render(); + expect(container.querySelector(".title")?.textContent).toBe("coder: Implement feature X"); + }); + }); + + // when a matching child session exists + context("対応する子セッションがある場合", () => { + const childSession = createSession({ id: "child-1", title: "Implement feature X" }); + const propsWithChild = { + ...defaultProps, + childSessions: [childSession], + onNavigateToChild: vi.fn(), + }; + + // renders the chevron navigate icon + it("ナビゲーションシェブロンアイコンを表示すること", () => { + const { container } = render(); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + }); + + // calls onNavigateToChild with child session id on click + it("クリックで子セッションIDを渡して onNavigateToChild を呼ぶこと", async () => { + const user = userEvent.setup(); + const { container } = render(); + await user.click(container.querySelector(".header")!); + expect(propsWithChild.onNavigateToChild).toHaveBeenCalledWith("child-1"); + }); + }); + + // when no matching child session exists + context("対応する子セッションがない場合", () => { + // does not render the chevron navigate icon + it("ナビゲーションシェブロンアイコンを表示しないこと", () => { + const { container } = render(); + expect(container.querySelector(".navigate")).not.toBeInTheDocument(); + }); + + // does not call onNavigateToChild on click + it("クリックしても onNavigateToChild を呼ばないこと", async () => { + const user = userEvent.setup(); + const onNav = vi.fn(); + const { container } = render(); + await user.click(container.querySelector(".header")!); + expect(onNav).not.toHaveBeenCalled(); + }); + }); + + // when child session matches by prompt instead of description + context("prompt で子セッションが一致する場合", () => { + const part = createSubtaskPart("coder", "desc-no-match", { prompt: "Find the bug" }); + const childSession = createSession({ id: "child-2", title: "Find the bug" }); + + // renders the chevron navigate icon + it("ナビゲーションシェブロンアイコンを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + }); + }); + + // task tool part rendering + context("task ツール呼び出し(type: tool, tool: task)を描画した場合", () => { + const taskPart = createTaskToolPart("general", "Search for relevant files"); + const taskProps = { + part: taskPart, + childSessions: [] as ReturnType[], + onNavigateToChild: vi.fn(), + }; + + // renders the Agent label + it("Agent ラベルをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".action")?.textContent).toBe("Agent"); + }); + + // renders the agent name and description from input + it("エージェント名と説明を state.input から表示すること", () => { + const { container } = render(); + expect(container.querySelector(".title")?.textContent).toBe("general: Search for relevant files"); + }); + + // navigates to matching child session + it("対応する子セッションにナビゲートできること", async () => { + const user = userEvent.setup(); + const childSession = createSession({ id: "child-task", title: "Search for relevant files" }); + const onNav = vi.fn(); + const { container } = render( + , + ); + await user.click(container.querySelector(".header")!); + expect(onNav).toHaveBeenCalledWith("child-task"); + }); + }); + + // task tool part with running status + context("task ツールが実行中の場合", () => { + const runningTaskPart = createTaskToolPart("explore", "Analyze codebase", { + state: { + status: "running", + title: "Analyze codebase", + input: { subagent_type: "explore", description: "Analyze codebase" }, + time: { start: Date.now() }, + }, + }); + + // shows spinner instead of agent icon + it("エージェントアイコンの代わりにスピナーを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".spinner")).toBeInTheDocument(); + }); + }); + + // task tool part with error status + context("task ツールがエラーの場合", () => { + const errorTaskPart = createTaskToolPart("general", "Failed task", { + state: { + status: "error", + input: { subagent_type: "general", description: "Failed task" }, + error: "Something went wrong", + time: { start: Date.now(), end: Date.now() }, + }, + }); + + // shows error styling on action label + it("アクションラベルにエラースタイルを適用すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".actionError")).toBeInTheDocument(); + }); + + // shows error message + it("エラーメッセージを表示すること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".errorText")?.textContent).toBe("Something went wrong"); + }); + }); +}); + +describe("isTaskToolPart", () => { + // identifies task tool parts + it("task ツールパートを識別すること", () => { + expect(isTaskToolPart({ type: "tool", tool: "task" })).toBe(true); + }); + + // rejects non-task tool parts + it("task 以外のツールパートを拒否すること", () => { + expect(isTaskToolPart({ type: "tool", tool: "read" })).toBe(false); + }); + + // rejects non-tool parts + it("type が tool でないパートを拒否すること", () => { + expect(isTaskToolPart({ type: "subtask" })).toBe(false); + }); +}); + +describe("findMatchingChild", () => { + // returns undefined when childSessions is empty + context("子セッションが空の場合", () => { + it("undefined を返すこと", () => { + const part = createTaskToolPart("general", "Do something"); + expect(findMatchingChild(part, [], "Do something", "Do something")).toBeUndefined(); + }); + }); + + // matches by metadata.sessionId (Strategy 1) + context("state.metadata.sessionId で直接マッチする場合", () => { + it("metadata の sessionId で子セッションを特定すること", () => { + const childSession = createSession({ id: "child-meta-1", title: "Unrelated title" }); + const part = createTaskToolPart("general", "Some task", { + state: { + status: "completed", + title: "Some task", + input: { subagent_type: "general", description: "Some task", prompt: "Some task" }, + output: "Done", + metadata: { sessionId: "child-meta-1" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + expect(findMatchingChild(part, [childSession], "Some task", "Some task")).toBe(childSession); + }); + + it("metadata.sessionId が childSessions にない場合はフォールバックすること", () => { + const childSession = createSession({ id: "child-2", title: "Some task" }); + const part = createTaskToolPart("general", "Some task", { + state: { + status: "completed", + title: "Some task", + input: { subagent_type: "general", description: "Some task", prompt: "Some task" }, + output: "Done", + metadata: { sessionId: "non-existent-id" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + // Falls back to Strategy 2 (title includes description) + expect(findMatchingChild(part, [childSession], "Some task", "Some task")).toBe(childSession); + }); + }); + + // matches by partial title containing description (Strategy 2 — server appends suffix) + context("子セッション title にサーバー付与のサフィックスがある場合", () => { + it("description が title に含まれる場合マッチすること", () => { + // Server sets title as: "Fix the bug (@coder subagent)" + const childSession = createSession({ id: "child-3", title: "Fix the bug (@coder subagent)" }); + const part = createSubtaskPart("coder", "Fix the bug"); + expect(findMatchingChild(part, [childSession], "Fix the bug", "Fix the bug")).toBe(childSession); + }); + }); + + // matches by prompt when description doesn't match (Strategy 3) + context("description ではマッチせず prompt でマッチする場合", () => { + it("prompt が title に含まれる場合マッチすること", () => { + const childSession = createSession({ id: "child-4", title: "Find critical bugs (@general subagent)" }); + const part = createSubtaskPart("general", "unrelated-desc", { prompt: "Find critical bugs" }); + expect(findMatchingChild(part, [childSession], "unrelated-desc", "Find critical bugs")).toBe(childSession); + }); + }); + + // returns undefined when no strategy matches + context("どの戦略でもマッチしない場合", () => { + it("undefined を返すこと", () => { + const childSession = createSession({ id: "child-5", title: "Completely different task" }); + const part = createSubtaskPart("coder", "Some description", { prompt: "Some prompt" }); + expect(findMatchingChild(part, [childSession], "Some description", "Some prompt")).toBeUndefined(); + }); + }); + + // skips metadata check for pending status + context("task ツールが pending 状態の場合", () => { + it("metadata チェックをスキップして title マッチにフォールバックすること", () => { + const childSession = createSession({ id: "child-6", title: "Pending task (@agent subagent)" }); + const part = createTaskToolPart("agent", "Pending task", { + state: { + status: "pending", + input: {}, + raw: "", + }, + }); + expect(findMatchingChild(part, [childSession], "Pending task", "")).toBe(childSession); + }); + }); + + // metadata takes priority over title match + context("metadata と title の両方でマッチ可能な場合", () => { + it("metadata.sessionId のマッチを優先すること", () => { + const childByTitle = createSession({ id: "child-title", title: "Do X" }); + const childByMeta = createSession({ id: "child-meta", title: "Different" }); + const part = createTaskToolPart("general", "Do X", { + state: { + status: "completed", + title: "Do X", + input: { subagent_type: "general", description: "Do X", prompt: "Do X" }, + output: "Done", + metadata: { sessionId: "child-meta" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + expect(findMatchingChild(part, [childByTitle, childByMeta], "Do X", "Do X")).toBe(childByMeta); + }); + }); +}); + +// Component: navigate with server-format title (suffix appended) +describe("SubtaskPartView — サーバー形式のタイトルマッチング", () => { + context("子セッションの title にサフィックスが付与されている場合", () => { + it("description を含む title にマッチしてナビゲートできること", async () => { + const user = userEvent.setup(); + const childSession = createSession({ id: "child-suffix", title: "Analyze code (@coder subagent)" }); + const part = createTaskToolPart("coder", "Analyze code"); + const onNav = vi.fn(); + const { container } = render( + , + ); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + await user.click(container.querySelector(".header")!); + expect(onNav).toHaveBeenCalledWith("child-suffix"); + }); + }); + + context("metadata.sessionId で子セッションに直接ナビゲートする場合", () => { + it("metadata の sessionId を使って正しい子セッションにナビゲートすること", async () => { + const user = userEvent.setup(); + const childSession = createSession({ id: "child-from-meta", title: "Server assigned title" }); + const part = createTaskToolPart("general", "My task", { + state: { + status: "completed", + title: "My task", + input: { subagent_type: "general", description: "My task", prompt: "details here" }, + output: "ok", + metadata: { sessionId: "child-from-meta" }, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + }); + const onNav = vi.fn(); + const { container } = render( + , + ); + expect(container.querySelector(".navigate")).toBeInTheDocument(); + await user.click(container.querySelector(".header")!); + expect(onNav).toHaveBeenCalledWith("child-from-meta"); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/ToolConfigPanel.test.tsx b/webview/__tests__/components/organisms/ToolConfigPanel.test.tsx new file mode 100644 index 0000000..4e66f98 --- /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(".root")).toBeInTheDocument(); + }); + + // renders close button + it("閉じるボタンをレンダリングすること", () => { + const { container } = render(); + expect(container.querySelector(".muted.sm")).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(".footer button")).toHaveLength(2); + }); + }); + + // when paths are null + context("paths が null の場合", () => { + // does not render config file links + it("設定ファイルリンクをレンダリングしないこと", () => { + const { container } = render(); + expect(container.querySelector(".footer")).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..e018ef4 --- /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(".header")).toBeInTheDocument(); + }); + + // renders the action icon + it("アクションアイコンをレンダリングすること", () => { + const part = createToolPart("file_read"); + const { container } = render(); + expect(container.querySelector(".icon")).toBeInTheDocument(); + }); + + // renders the action label + it("アクションラベルをレンダリングすること", () => { + const part = createToolPart("file_read"); + const { container } = render(); + expect(container.querySelector(".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(".header")!); + expect(container.querySelector(".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(".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(".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(".header")!); + expect(container.querySelector("[data-testid='diff-view']")).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(".header")!); + expect(container.querySelector("[data-testid='diff-view']")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/factories.ts b/webview/__tests__/factories.ts index c87a526..f8ab4fb 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 --- @@ -59,6 +59,47 @@ export function createToolPart(tool: string, overrides: Partial = {}): } as unknown as ToolPart; } +/** task ツール呼び出し(サブエージェント起動)のファクトリ */ +export function createTaskToolPart( + agentName: string, + description: string, + overrides: Record = {}, +): ToolPart { + return { + id: `part-${++partSeq}`, + type: "tool", + tool: "task", + callID: `call-${partSeq}`, + sessionID: "session-1", + messageID: "msg-1", + state: { + status: "completed", + title: description, + input: { subagent_type: agentName, description, prompt: description }, + output: "Task completed successfully.", + metadata: {}, + time: { start: Date.now() - 1000, end: Date.now() }, + }, + ...overrides, + } as unknown as ToolPart; +} + +// --- SubtaskPart --- + +export function createSubtaskPart(agent: string, description: string, overrides: Record = {}) { + return { + id: `part-${++partSeq}`, + type: "subtask" as const, + sessionID: "session-1", + messageID: "msg-1", + prompt: description, + description, + agent, + time: { created: Date.now(), updated: Date.now() }, + ...overrides, + }; +} + // --- Permission --- export function createPermission(overrides: Partial = {}): Permission { @@ -73,7 +114,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__/hooks/useClickOutside.test.ts b/webview/__tests__/hooks/useClickOutside.test.ts new file mode 100644 index 0000000..fb9043b --- /dev/null +++ b/webview/__tests__/hooks/useClickOutside.test.ts @@ -0,0 +1,160 @@ +import { act, renderHook } from "@testing-library/react"; +import { createRef, type RefObject } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { useClickOutside } from "../../hooks/useClickOutside"; + +/** mousedown イベントを指定ターゲットで発火するヘルパー */ +function fireMousedown(target: EventTarget) { + const event = new MouseEvent("mousedown", { bubbles: true }); + Object.defineProperty(event, "target", { value: target }); + document.dispatchEvent(event); +} + +/** ダミー DOM 要素を作成し、contains を制御できるようにする */ +function createMockElement(containsTarget: boolean): Element { + const el = document.createElement("div"); + vi.spyOn(el, "contains").mockReturnValue(containsTarget); + return el; +} + +describe("useClickOutside", () => { + // single ref — click outside fires callback + context("単一 ref で外側をクリックした場合", () => { + // fires callback on outside click + it("コールバックが呼ばれること", () => { + const callback = vi.fn(); + const ref = createRef() as RefObject; + (ref as { current: Element }).current = createMockElement(false); + + renderHook(() => useClickOutside(ref, callback)); + act(() => fireMousedown(document.body)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + // single ref — click inside does not fire callback + context("単一 ref で内側をクリックした場合", () => { + // does not fire callback on inside click + it("コールバックが呼ばれないこと", () => { + const callback = vi.fn(); + const ref = createRef() as RefObject; + (ref as { current: Element }).current = createMockElement(true); + + renderHook(() => useClickOutside(ref, callback)); + act(() => fireMousedown(document.body)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + // enabled = false — does not fire callback + context("enabled が false の場合", () => { + // does not register listener when disabled + it("コールバックが呼ばれないこと", () => { + const callback = vi.fn(); + const ref = createRef() as RefObject; + (ref as { current: Element }).current = createMockElement(false); + + renderHook(() => useClickOutside(ref, callback, false)); + act(() => fireMousedown(document.body)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + // enabled toggles — registers and unregisters listener + context("enabled が true から false に変わった場合", () => { + // unregisters listener when disabled + it("リスナーが解除されコールバックが呼ばれないこと", () => { + const callback = vi.fn(); + const ref = createRef() as RefObject; + (ref as { current: Element }).current = createMockElement(false); + + const { rerender } = renderHook(({ enabled }) => useClickOutside(ref, callback, enabled), { + initialProps: { enabled: true }, + }); + + // 有効時: 外部クリックで発火 + act(() => fireMousedown(document.body)); + expect(callback).toHaveBeenCalledTimes(1); + + // 無効に切り替え + rerender({ enabled: false }); + act(() => fireMousedown(document.body)); + + // 追加で呼ばれていないこと + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + // multiple refs — click outside all refs fires callback + context("複数 ref で全要素の外側をクリックした場合", () => { + // fires callback when outside all refs + it("コールバックが呼ばれること", () => { + const callback = vi.fn(); + const ref1 = createRef() as RefObject; + const ref2 = createRef() as RefObject; + (ref1 as { current: Element }).current = createMockElement(false); + (ref2 as { current: Element }).current = createMockElement(false); + + renderHook(() => useClickOutside([ref1, ref2], callback)); + act(() => fireMousedown(document.body)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + // multiple refs — click inside one ref does not fire callback + context("複数 ref でいずれかの要素の内側をクリックした場合", () => { + // does not fire callback when inside one of the refs + it("コールバックが呼ばれないこと", () => { + const callback = vi.fn(); + const ref1 = createRef() as RefObject; + const ref2 = createRef() as RefObject; + (ref1 as { current: Element }).current = createMockElement(false); + (ref2 as { current: Element }).current = createMockElement(true); // inside ref2 + + renderHook(() => useClickOutside([ref1, ref2], callback)); + act(() => fireMousedown(document.body)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + // ref.current is null — fires callback (element not mounted) + context("ref.current が null の場合", () => { + // treats null ref as outside + it("コールバックが呼ばれること", () => { + const callback = vi.fn(); + const ref = createRef() as RefObject; + // ref.current is null by default + + renderHook(() => useClickOutside(ref, callback)); + act(() => fireMousedown(document.body)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + // callback update is picked up without re-subscribing + context("コールバックが更新された場合", () => { + // uses the latest callback + it("最新のコールバックが呼ばれること", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const ref = createRef() as RefObject; + (ref as { current: Element }).current = createMockElement(false); + + const { rerender } = renderHook(({ cb }) => useClickOutside(ref, cb), { + initialProps: { cb: callback1 }, + }); + + rerender({ cb: callback2 }); + act(() => fireMousedown(document.body)); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/webview/__tests__/hooks/useFileChanges.test.ts b/webview/__tests__/hooks/useFileChanges.test.ts new file mode 100644 index 0000000..73a84c1 --- /dev/null +++ b/webview/__tests__/hooks/useFileChanges.test.ts @@ -0,0 +1,77 @@ +import type { Event, FileDiff } from "@opencode-ai/sdk"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useFileChanges } from "../../hooks/useFileChanges"; + +function createFileDiff(overrides: Partial = {}): FileDiff { + return { + file: "src/index.ts", + before: "const a = 1;", + after: "const a = 2;", + additions: 1, + deletions: 1, + ...overrides, + }; +} + +describe("useFileChanges", () => { + // initial state + context("初期状態の場合", () => { + // diffs is empty + it("diffs が空配列であること", () => { + const { result } = renderHook(() => useFileChanges()); + expect(result.current.diffs).toEqual([]); + }); + }); + + // setDiffs + context("setDiffs を呼んだ場合", () => { + // updates diffs state + it("diffs が更新されること", () => { + const { result } = renderHook(() => useFileChanges()); + const diff = createFileDiff(); + act(() => result.current.setDiffs([diff])); + expect(result.current.diffs).toEqual([diff]); + }); + }); + + // clearDiffs + context("clearDiffs を呼んだ場合", () => { + // clears diffs + it("diffs が空になること", () => { + const { result } = renderHook(() => useFileChanges()); + act(() => result.current.setDiffs([createFileDiff()])); + act(() => result.current.clearDiffs()); + expect(result.current.diffs).toEqual([]); + }); + }); + + // handleFileChangeEvent + context("session.diff イベントを受け取った場合", () => { + // updates diffs from event + it("イベントの diff データで diffs を更新すること", () => { + const { result } = renderHook(() => useFileChanges()); + const diff = createFileDiff({ file: "updated.ts", additions: 10, deletions: 3 }); + const event = { + type: "session.diff", + properties: { sessionID: "s1", diff: [diff] }, + } as unknown as Event; + act(() => result.current.handleFileChangeEvent(event)); + expect(result.current.diffs).toEqual([diff]); + }); + }); + + // ignores unrelated events + context("関係ないイベントを受け取った場合", () => { + // does not change diffs + it("diffs が変更されないこと", () => { + const { result } = renderHook(() => useFileChanges()); + const event = { + type: "message.updated", + properties: {}, + } as unknown as Event; + act(() => result.current.handleFileChangeEvent(event)); + expect(result.current.diffs).toEqual([]); + }); + }); +}); diff --git a/webview/__tests__/hooks/useLocale.test.ts b/webview/__tests__/hooks/useLocale.test.ts new file mode 100644 index 0000000..f7be8f1 --- /dev/null +++ b/webview/__tests__/hooks/useLocale.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } 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..cab04cb --- /dev/null +++ b/webview/__tests__/hooks/useMessages.test.ts @@ -0,0 +1,235 @@ +import type { Event } from "@opencode-ai/sdk"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { type MessageWithParts, useMessages } 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); + }); + + // 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); + }); + }); + + // markPendingShell and isShellMessage + context("markPendingShell を呼び出した場合", () => { + // tags next assistant message as shell + it("次の assistant メッセージがシェルメッセージとしてタグ付けされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + const event = { + type: "message.updated", + properties: { info: { id: "shell-a1", role: "assistant" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.isShellMessage("shell-a1")).toBe(true); + }); + + // tags user message as shell + it("user メッセージもシェルメッセージとしてタグ付けされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + const event = { + type: "message.updated", + properties: { info: { id: "shell-u1", role: "user" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.isShellMessage("shell-u1")).toBe(true); + }); + + // clears pending flag after assistant message + it("assistant メッセージ後にフラグがクリアされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + // user message arrives first + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "u1", role: "user" } }, + } as unknown as Event), + ); + // assistant message arrives and clears the flag + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "a1", role: "assistant" } }, + } as unknown as Event), + ); + // next message should NOT be tagged + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "a2", role: "assistant" } }, + } as unknown as Event), + ); + expect(result.current.isShellMessage("a2")).toBe(false); + }); + + // does not clear pending flag on user message alone + it("user メッセージだけではフラグがクリアされないこと", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "u1", role: "user" } }, + } as unknown as Event), + ); + // next assistant should still be tagged + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "a1", role: "assistant" } }, + } as unknown as Event), + ); + expect(result.current.isShellMessage("a1")).toBe(true); + }); + }); + + // isShellMessage returns false for normal messages + context("markPendingShell を呼び出していない場合", () => { + // returns false for normal messages + it("通常メッセージの isShellMessage が false を返すこと", () => { + const { result } = renderHook(() => useMessages()); + const event = { + type: "message.updated", + properties: { info: { id: "m1", role: "assistant" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.isShellMessage("m1")).toBe(false); + }); + }); +}); diff --git a/webview/__tests__/hooks/usePermissions.test.ts b/webview/__tests__/hooks/usePermissions.test.ts new file mode 100644 index 0000000..fd1fe3e --- /dev/null +++ b/webview/__tests__/hooks/usePermissions.test.ts @@ -0,0 +1,73 @@ +import type { Event } from "@opencode-ai/sdk"; +import { act, renderHook } 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..1bf1f37 --- /dev/null +++ b/webview/__tests__/hooks/useProviders.test.ts @@ -0,0 +1,99 @@ +import { act, renderHook } 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..700abd7 --- /dev/null +++ b/webview/__tests__/hooks/useSession.test.ts @@ -0,0 +1,157 @@ +import type { Event } from "@opencode-ai/sdk"; +import { act, renderHook } 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/__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..b4d75b4 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 @@ -120,7 +118,7 @@ describe("メッセージング", () => { // StreamingIndicator shows 3 dots it("StreamingIndicator のドットが3つ表示されること", () => { - const dots = document.querySelectorAll(".streaming-dot"); + const dots = document.querySelectorAll(".dot"); expect(dots.length).toBe(3); }); @@ -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..17b12c5 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() { @@ -45,6 +44,7 @@ describe("ファイルコンテキスト", () => { expect(screen.getByPlaceholderText("Search files...")).toBeInTheDocument(); }); + // Selecting a file shows a chip and closes the picker // Selecting a file shows a chip and closes the picker context("ファイルを選択した場合", () => { beforeEach(async () => { @@ -52,13 +52,14 @@ 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(".pickerList > div"))?.closest(".pickerList > div"); + if (!clickTarget) throw new Error("list-item not found"); + await user.click(clickTarget); }); // Chip is shown it("チップが表示されること", () => { - const chips = document.querySelectorAll(".attached-file-chip"); + const chips = document.querySelectorAll(".chip"); expect(chips.length).toBe(1); }); @@ -76,17 +77,18 @@ 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(".pickerList > div"))?.closest(".pickerList > div"); + if (!clickTarget) throw new Error("list-item not found"); + await user.click(clickTarget); // チップが表示される - expect(document.querySelectorAll(".attached-file-chip").length).toBe(1); + expect(document.querySelectorAll(".chip").length).toBe(1); // 削除ボタンをクリック await user.click(screen.getByTitle("Remove")); // チップが消える - expect(document.querySelectorAll(".attached-file-chip").length).toBe(0); + expect(document.querySelectorAll(".chip").length).toBe(0); }); // # trigger shows file candidate popup @@ -98,7 +100,7 @@ describe("ファイルコンテキスト", () => { await user.type(textarea, "#"); // ハッシュポップアップが表示される - const popup = document.querySelector(".hash-popup"); + const popup = document.querySelector("[data-testid='hash-popup']"); expect(popup).toBeTruthy(); }); @@ -125,8 +127,8 @@ describe("ファイルコンテキスト", () => { const user = userEvent.setup(); 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 popup = document.querySelector("[data-testid='hash-popup']"); + const popupItem = popup?.querySelector(":scope > div"); await user.click(popupItem!); }); @@ -137,7 +139,7 @@ describe("ファイルコンテキスト", () => { // File chip is shown it("ファイルチップが表示されること", () => { - expect(document.querySelectorAll(".attached-file-chip").length).toBe(1); + expect(document.querySelectorAll(".chip").length).toBe(1); }); }); @@ -149,8 +151,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(".pickerList > div"))?.closest(".pickerList > div"); + if (!clickTarget) throw new Error("list-item not found"); + await user.click(clickTarget); // メッセージ送信 const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); @@ -178,7 +181,7 @@ describe("ファイルコンテキスト", () => { await user.click(quickAdd); // チップが表示される - const chips = document.querySelectorAll(".attached-file-chip"); + const chips = document.querySelectorAll(".chip"); expect(chips.length).toBe(1); }); @@ -189,13 +192,13 @@ describe("ファイルコンテキスト", () => { // quick-add で main.ts を添付 await user.click(screen.getByTitle("Add src/main.ts")); - expect(document.querySelectorAll(".attached-file-chip").length).toBe(1); + expect(document.querySelectorAll(".chip").length).toBe(1); // ファイルピッカーからもう一度 main.ts を選択しようとする await user.click(screen.getByTitle("Add context")); // main.ts は既に添付済みなのでピッカーのリストに表示されない(フィルタされている) - const pickerItems = document.querySelectorAll(".file-picker-item"); + const pickerItems = document.querySelectorAll(".pickerList > div"); const mainInPicker = Array.from(pickerItems).find((el) => el.textContent?.includes("main.ts")); expect(mainInPicker).toBeFalsy(); }); @@ -209,11 +212,11 @@ describe("ファイルコンテキスト", () => { await user.type(textarea, "#"); // ポップアップが開いている - expect(document.querySelector(".hash-popup")).toBeTruthy(); + expect(document.querySelector("[data-testid='hash-popup']")).toBeTruthy(); // Escape で閉じる await user.keyboard("{Escape}"); - expect(document.querySelector(".hash-popup")).toBeFalsy(); + expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy(); }); // Space input during # trigger terminates the trigger @@ -225,6 +228,6 @@ describe("ファイルコンテキスト", () => { await user.type(textarea, "#test "); // スペースを入力したのでポップアップが閉じる - expect(document.querySelector(".hash-popup")).toBeFalsy(); + expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy(); }); }); diff --git a/webview/__tests__/scenarios/08-context-compression.test.tsx b/webview/__tests__/scenarios/08-context-compression.test.tsx index fcb0094..d3e69f7 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", }); @@ -132,8 +150,8 @@ describe("コンテキストとコンプレッション", () => { await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); // トークン 0 → ContextIndicator ボタンが存在しない - const button = document.querySelector(".context-indicator-button"); - expect(button).toBeFalsy(); + const indicator = document.querySelector(".container > .button"); + expect(indicator).toBeFalsy(); }); // Warning color is applied at 80%+ usage @@ -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..fe1eca5 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", + }, }); }); @@ -75,7 +79,7 @@ describe("ツール表示", () => { it("展開すると差分行が表示されること", async () => { const user = userEvent.setup(); await user.click(screen.getByTitle("Toggle details")); - const diffLines = document.querySelectorAll(".tool-diff-line"); + const diffLines = document.querySelectorAll(".line"); expect(diffLines.length).toBeGreaterThan(0); }); }); @@ -110,7 +114,7 @@ describe("ツール表示", () => { it("展開すると全行が add の差分が表示されること", async () => { const user = userEvent.setup(); await user.click(screen.getByTitle("Toggle details")); - const addLines = document.querySelectorAll(".tool-diff-line-add"); + const addLines = document.querySelectorAll(".lineAdd"); expect(addLines.length).toBeGreaterThan(0); }); }); @@ -252,7 +256,7 @@ describe("ツール表示", () => { }); // スピナー SVG が存在する - const spinner = document.querySelector(".tool-part-spinner"); + const spinner = document.querySelector(".spinner"); expect(spinner).toBeTruthy(); }); @@ -308,7 +312,7 @@ describe("ツール表示", () => { }, }); - const spinner = document.querySelector(".tool-part-spinner"); + const spinner = document.querySelector(".spinner"); expect(spinner).toBeTruthy(); }); diff --git a/webview/__tests__/scenarios/11-todo.test.tsx b/webview/__tests__/scenarios/11-todo.test.tsx index 36fc76d..a25f71e 100644 --- a/webview/__tests__/scenarios/11-todo.test.tsx +++ b/webview/__tests__/scenarios/11-todo.test.tsx @@ -1,45 +1,25 @@ -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 { createEvent, 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 }>) { +/** session.todo() API 経由で Todo 付きのセッションをセットアップする */ +async function setupWithTodos(todos: Array<{ id: string; content: string; status: string; priority: string }>) { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); - - const msg = createMessage({ id: "m1", sessionID: "s1", role: "assistant" }); - const todoPart = { - id: "tp1", - type: "tool" as const, - tool: "todowrite", - messageID: "m1", - time: { created: Date.now(), updated: Date.now() }, - state: { - status: "completed", - title: "todowrite", - input: { todos }, - output: JSON.stringify(todos), - }, - }; - - await sendExtMessage({ - type: "messages", - sessionId: "s1", - messages: [{ info: msg, parts: [todoPart as any] }], - }); + await sendExtMessage({ type: "sessionTodos", sessionId: "s1", todos }); } // Todo describe("Todo", () => { - // TodoHeader is shown from todowrite output - context("todowrite 出力から TodoHeader を表示した場合", () => { + // TodoHeader is shown from session.todo() API + context("session.todo() API から TodoHeader を表示した場合", () => { beforeEach(async () => { await setupWithTodos([ - { content: "First task", status: "completed" }, - { content: "Second task", status: "in-progress" }, - { content: "Third task", status: "pending" }, + { id: "t1", content: "First task", status: "completed", priority: "medium" }, + { id: "t2", content: "Second task", status: "in_progress", priority: "medium" }, + { id: "t3", content: "Third task", status: "pending", priority: "low" }, ]); }); @@ -58,8 +38,8 @@ describe("Todo", () => { context("Todo 一覧を展開した場合", () => { beforeEach(async () => { await setupWithTodos([ - { content: "Implement feature", status: "completed" }, - { content: "Write tests", status: "in-progress", priority: "high" }, + { id: "t1", content: "Implement feature", status: "completed", priority: "medium" }, + { id: "t2", content: "Write tests", status: "in_progress", priority: "high" }, ]); const user = userEvent.setup(); @@ -87,37 +67,25 @@ describe("Todo", () => { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); - // メッセージなし → TodoHeader なし + // Todo なし → TodoHeader なし expect(screen.queryByText("To Do")).not.toBeInTheDocument(); }); - // Todos are also shown from todoread tool output - it("todoread ツールの出力からも Todo が表示されること", async () => { + // todo.updated SSE event updates TodoHeader in real-time + it("todo.updated SSE イベントで TodoHeader がリアルタイム更新されること", async () => { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); - const msg = createMessage({ id: "m1", sessionID: "s1", role: "assistant" }); - const todoPart = { - id: "tp1", - type: "tool" as const, - tool: "todoread", - messageID: "m1", - time: { created: Date.now(), updated: Date.now() }, - state: { - status: "completed", - title: "todoread", - input: {}, - output: JSON.stringify([ - { content: "Read task 1", status: "completed" }, - { content: "Read task 2", status: "pending" }, - ]), - }, - }; - + // todo.updated イベントで Todo を受信 await sendExtMessage({ - type: "messages", - sessionId: "s1", - messages: [{ info: msg, parts: [todoPart as any] }], + type: "event", + event: createEvent("todo.updated", { + sessionID: "s1", + todos: [ + { id: "t1", content: "SSE task 1", status: "completed", priority: "medium" }, + { id: "t2", content: "SSE task 2", status: "pending", priority: "low" }, + ], + }), }); expect(screen.getByText("1/2")).toBeInTheDocument(); @@ -126,10 +94,53 @@ describe("Todo", () => { // Count matches when all todos are completed it("全て完了のとき件数が一致すること", async () => { await setupWithTodos([ - { content: "Task A", status: "completed" }, - { content: "Task B", status: "done" }, + { id: "t1", content: "Task A", status: "completed", priority: "medium" }, + { id: "t2", content: "Task B", status: "completed", priority: "low" }, ]); expect(screen.getByText("2/2")).toBeInTheDocument(); }); + + // Todos are cleared when switching to a session without todos + it("セッション切替で Todo がクリアされること", async () => { + await setupWithTodos([{ id: "t1", content: "Some task", status: "pending", priority: "medium" }]); + expect(screen.getByText("To Do")).toBeInTheDocument(); + + // 別のセッション(Todo なし)に切替 + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s2" }) }); + // 新セッションの sessionTodos 応答(空)でクリアされる + await sendExtMessage({ type: "sessionTodos", sessionId: "s2", todos: [] }); + + expect(screen.queryByText("To Do")).not.toBeInTheDocument(); + }); + + // todo.updated for a different session is ignored + it("別セッションの todo.updated イベントは無視されること", async () => { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + + // 別セッション (s999) の todo.updated + await sendExtMessage({ + type: "event", + event: createEvent("todo.updated", { + sessionID: "s999", + todos: [{ id: "t1", content: "Other session task", status: "pending", priority: "medium" }], + }), + }); + + expect(screen.queryByText("To Do")).not.toBeInTheDocument(); + }); + + // Todos are preserved when activeSession message is re-sent for the same session + it("同じセッションの activeSession 再送で Todo がクリアされないこと", async () => { + await setupWithTodos([{ id: "t1", content: "Persistent task", status: "pending", priority: "medium" }]); + expect(screen.getByText("To Do")).toBeInTheDocument(); + + // 同じセッション s1 の activeSession が再送される(session.updated 等で発生しうる) + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + + // Todo はクリアされず維持される + expect(screen.getByText("To Do")).toBeInTheDocument(); + expect(screen.getByText("0/1")).toBeInTheDocument(); + }); }); diff --git a/webview/__tests__/scenarios/12-reasoning-display.test.tsx b/webview/__tests__/scenarios/12-reasoning-display.test.tsx index ec2aa3e..ff5ef4c 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 = {}) { @@ -34,7 +34,7 @@ describe("思考表示(ReasoningPartView)", () => { beforeEach(async () => { await setupWithReasoningPart({ time: { created: Date.now() } }); - part = screen.getByText("Thinking\u2026").closest(".reasoning-part"); + part = screen.getByText("Thinking\u2026").closest(".reasoningPart"); }); // Shows Thinking label @@ -43,13 +43,13 @@ describe("思考表示(ReasoningPartView)", () => { }); // Has active class - it("active クラスが付与されること", () => { - expect(part).toHaveClass("active"); + it("reasoningActive クラスが付与されること", () => { + expect(part).toHaveClass("reasoningActive"); }); // Shows spinner it("スピナーが表示されること", () => { - expect(part!.querySelector(".tool-part-spinner")).toBeInTheDocument(); + expect(part?.querySelector(".spinner")).toBeInTheDocument(); }); }); @@ -59,7 +59,7 @@ describe("思考表示(ReasoningPartView)", () => { beforeEach(async () => { await setupWithReasoningPart({ time: { created: Date.now(), end: Date.now() } }); - part = screen.getByText("Thought").closest(".reasoning-part"); + part = screen.getByText("Thought").closest(".reasoningPart"); }); // Shows Thought label @@ -67,14 +67,14 @@ describe("思考表示(ReasoningPartView)", () => { expect(screen.getByText("Thought")).toBeInTheDocument(); }); - // Has complete class - it("complete クラスが付与されること", () => { - expect(part).toHaveClass("complete"); + // Has no active class (complete state) + it("reasoningActive クラスが付与されないこと", () => { + expect(part).not.toHaveClass("reasoningActive"); }); // No spinner it("スピナーが表示されないこと", () => { - expect(part!.querySelector(".tool-part-spinner")).not.toBeInTheDocument(); + expect(part?.querySelector(".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__/scenarios/14-shell-command.test.tsx b/webview/__tests__/scenarios/14-shell-command.test.tsx new file mode 100644 index 0000000..368a391 --- /dev/null +++ b/webview/__tests__/scenarios/14-shell-command.test.tsx @@ -0,0 +1,322 @@ +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, createToolPart } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** アクティブセッションを持つ状態をセットアップする */ +async function setupActiveSession() { + renderApp(); + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + return session; +} + +// Shell command execution +describe("シェルコマンド実行", () => { + // ! prefix sends executeShell instead of sendMessage + it("! プレフィクスで executeShell が送信されること", async () => { + const session = await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!ls -la{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "executeShell", + sessionId: session.id, + command: "ls -la", + }), + ); + }); + + // ! prefix does not send sendMessage + it("! プレフィクスの場合 sendMessage が送信されないこと", async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!echo hello{Enter}"); + + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" })); + }); + + // ! only (no command) does not send + it("! のみでは送信されないこと", async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!{Enter}"); + + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "executeShell" })); + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" })); + }); + + // Shell mode indicator appears when typing ! + context("! を入力した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!"); + }); + + // Shell mode indicator is displayed + it("シェルモードインジケーターが表示されること", () => { + expect(screen.getByText("Shell mode")).toBeInTheDocument(); + }); + + // Placeholder changes to shell-specific text + it("プレースホルダーがシェルコマンド用に変わること", () => { + expect(screen.getByPlaceholderText("Enter shell command...")).toBeInTheDocument(); + }); + }); + + // Shell mode indicator disappears when ! is removed + context("! を削除した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!"); + await user.clear(textarea); + }); + + // Shell mode indicator is hidden + it("シェルモードインジケーターが非表示になること", () => { + expect(screen.queryByText("Shell mode")).not.toBeInTheDocument(); + }); + }); + + // Text without ! prefix sends normal message + it("! なしのテキストは通常の sendMessage として送信されること", async () => { + const session = await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Hello world{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + sessionId: session.id, + text: "Hello world", + }), + ); + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "executeShell" })); + }); + + // executeShell includes selectedModel + it("executeShell に selectedModel が含まれること", async () => { + renderApp(); + + const provider = createProvider("anthropic", { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }); + 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 } }, + }, + }, + ], + ), + default: { general: "anthropic/claude-4-opus" }, + configModel: "anthropic/claude-4-opus", + }); + + const session = createSession({ id: "s1" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!git status{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "executeShell", + sessionId: "s1", + command: "git status", + model: { providerID: "anthropic", modelID: "claude-4-opus" }, + }), + ); + }); + + // Input is cleared after shell command execution + it("シェルコマンド実行後に入力欄がクリアされること", async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!pwd{Enter}"); + + expect(textarea).toHaveValue(""); + }); + + // Shell result is rendered with ShellResultView (terminal-style) + context("シェルコマンドの結果を受信した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + // ! プレフィクスでシェルコマンドを送信 + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!ls{Enter}"); + + // サーバーからの応答をシミュレート: user メッセージ(シェルコマンドのトリガー) + const userMsg = createMessage({ id: "shell-u1", sessionID: "s1", role: "user" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: userMsg } } as any, + }); + + // サーバーからの応答をシミュレート: message.updated(新しい assistant メッセージ) + const shellMsg = createMessage({ id: "shell-m1", sessionID: "s1", role: "assistant" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: shellMsg } } as any, + }); + + // ツールパート(bash の実行結果) + const shellPart = createToolPart("bash", { + messageID: "shell-m1", + state: { + status: "completed", + title: "ls", + input: { command: "ls" }, + output: "file1.txt\nfile2.txt", + }, + } as any); + await sendExtMessage({ + type: "event", + event: { type: "message.part.updated", properties: { part: shellPart } } as any, + }); + }); + + // Shell result is displayed in terminal style (shows "Shell" header) + it("ターミナル風のシェル結果ヘッダーが表示されること", () => { + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + + // Shell output is visible without clicking (expanded by default) + it("シェル出力がデフォルトで展開表示されること", () => { + expect(screen.getByText(/file1\.txt/)).toBeInTheDocument(); + }); + + // User message bubble is hidden for shell commands + it("シェルコマンドのユーザー吹き出しが非表示であること", () => { + // ユーザーメッセージは ShellResultView の "$ command" で表示されるため + // 通常のユーザー吹き出し(クリックで編集可能な要素)は表示されない + const userBubbles = document.querySelectorAll("[class*='userBubble']"); + expect(userBubbles).toHaveLength(0); + }); + + // $ prompt is rendered for the command + it("$ プロンプトとコマンドが表示されること", () => { + expect(screen.getByText("$")).toBeInTheDocument(); + expect(screen.getByText("ls")).toBeInTheDocument(); + }); + }); + + // Shell result with error + context("シェルコマンドがエラーを返した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!bad-cmd{Enter}"); + + const userMsg = createMessage({ id: "err-u1", sessionID: "s1", role: "user" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: userMsg } } as any, + }); + + const shellMsg = createMessage({ id: "err-m1", sessionID: "s1", role: "assistant" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: shellMsg } } as any, + }); + + const shellPart = createToolPart("bash", { + messageID: "err-m1", + state: { + status: "error", + title: "bad-cmd", + input: { command: "bad-cmd" }, + error: "command not found: bad-cmd", + }, + } as any); + await sendExtMessage({ + type: "event", + event: { type: "message.part.updated", properties: { part: shellPart } } as any, + }); + }); + + // error message is displayed + it("エラーメッセージが表示されること", () => { + expect(screen.getByText("command not found: bad-cmd")).toBeInTheDocument(); + }); + + // error styling is applied + it("エラー用のスタイルが適用されること", () => { + const errorOutput = document.querySelector("[class*='outputError']"); + expect(errorOutput).toBeInTheDocument(); + }); + }); + + // Shell result in running state + context("シェルコマンドが実行中の場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!sleep 30{Enter}"); + + const userMsg = createMessage({ id: "run-u1", sessionID: "s1", role: "user" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: userMsg } } as any, + }); + + const shellMsg = createMessage({ id: "run-m1", sessionID: "s1", role: "assistant" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: shellMsg } } as any, + }); + + const shellPart = createToolPart("bash", { + messageID: "run-m1", + state: { + status: "running", + title: "sleep 30", + input: { command: "sleep 30" }, + }, + } as any); + await sendExtMessage({ + type: "event", + event: { type: "message.part.updated", properties: { part: shellPart } } as any, + }); + }); + + // spinner is displayed + it("スピナーが表示されること", () => { + const spinner = document.querySelector("[class*='spinner']"); + expect(spinner).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/scenarios/15-file-changes.test.tsx b/webview/__tests__/scenarios/15-file-changes.test.tsx new file mode 100644 index 0000000..e54e346 --- /dev/null +++ b/webview/__tests__/scenarios/15-file-changes.test.tsx @@ -0,0 +1,199 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** sessionDiff メッセージでファイル変更差分を送信する */ +async function sendSessionDiff( + sessionId: string, + diffs: Array<{ file: string; before: string; after: string; additions: number; deletions: number }>, +) { + await sendExtMessage({ + type: "sessionDiff", + sessionId, + diffs, + }); +} + +/** アクティブセッションとファイル変更差分をセットアップする */ +async function setupWithDiffs( + diffs: Array<{ file: string; before: string; after: string; additions: number; deletions: number }>, +) { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + await sendSessionDiff("s1", diffs); +} + +// File changes diff display +describe("ファイル変更差分表示", () => { + // FileChangesHeader is shown when sessionDiff arrives + context("sessionDiff メッセージを受信した場合", () => { + beforeEach(async () => { + await setupWithDiffs([ + { file: "src/index.ts", before: "const a = 1;", after: "const a = 2;", additions: 1, deletions: 1 }, + { file: "src/utils.ts", before: "", after: "export const b = 2;", additions: 1, deletions: 0 }, + ]); + }); + + // Shows File Changes label + it("File Changes ラベルが表示されること", () => { + expect(screen.getByText("File Changes")).toBeInTheDocument(); + }); + + // Shows file count + it("ファイル数が表示されること", () => { + expect(screen.getByText("2")).toBeInTheDocument(); + }); + }); + + // FileChangesHeader is hidden when there are no diffs + context("ファイル変更がない場合", () => { + // does not show FileChangesHeader + it("FileChangesHeader が表示されないこと", async () => { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + expect(screen.queryByText("File Changes")).not.toBeInTheDocument(); + }); + }); + + // Expanding shows file details + context("FileChangesHeader を展開した場合", () => { + beforeEach(async () => { + await setupWithDiffs([{ file: "src/app.ts", before: "old code", after: "new code", additions: 1, deletions: 1 }]); + const user = userEvent.setup(); + await user.click(screen.getByTitle("File changes")); + }); + + // Shows file name + it("ファイル名が表示されること", () => { + expect(screen.getByText("app.ts")).toBeInTheDocument(); + }); + + // Shows directory + it("ディレクトリパスが表示されること", () => { + expect(screen.getByText("src")).toBeInTheDocument(); + }); + }); + + // sessionDiff for a different session is ignored + context("異なるセッションの sessionDiff を受信した場合", () => { + // does not show FileChangesHeader + it("FileChangesHeader が表示されないこと", async () => { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + await sendSessionDiff("other-session", [ + { file: "src/x.ts", before: "", after: "code", additions: 1, deletions: 0 }, + ]); + expect(screen.queryByText("File Changes")).not.toBeInTheDocument(); + }); + }); + + // Diffs are cleared when switching to a null session + context("セッションが null に切り替わった場合", () => { + // hides FileChangesHeader + it("FileChangesHeader が非表示になること", async () => { + await setupWithDiffs([{ file: "src/a.ts", before: "a", after: "b", additions: 1, deletions: 1 }]); + expect(screen.getByText("File Changes")).toBeInTheDocument(); + + await sendExtMessage({ type: "activeSession", session: null as any }); + expect(screen.queryByText("File Changes")).not.toBeInTheDocument(); + }); + }); + + // session.diff SSE event updates the diff display + context("session.diff SSE イベントを受信した場合", () => { + // updates the file count + it("ファイル数が更新されること", async () => { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + + // 最初はファイル変更なし + expect(screen.queryByText("File Changes")).not.toBeInTheDocument(); + + // SSE event で差分を受信 + await sendExtMessage({ + type: "event", + event: { + type: "session.diff", + properties: { + sessionID: "s1", + diff: [{ file: "src/a.ts", before: "x", after: "y", additions: 1, deletions: 1 }], + }, + } as any, + }); + + expect(screen.getByText("File Changes")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + }); + + // Inline diff is shown when file item is expanded + context("ファイルアイテムを展開した場合", () => { + // shows diff view with changed lines + it("変更行を含む差分ビューが表示されること", async () => { + await setupWithDiffs([ + { file: "src/main.ts", before: "const x = 1;", after: "const x = 2;", additions: 1, deletions: 1 }, + ]); + const user = userEvent.setup(); + + // ヘッダーバーを展開 + await user.click(screen.getByTitle("File changes")); + + // ファイルアイテムをクリックして差分を表示 + const fileHeader = document.querySelector(".fileHeader")!; + await user.click(fileHeader); + + expect(screen.getByTestId("diff-view")).toBeInTheDocument(); + }); + }); + + // Open diff editor button sends message + context("差分エディタを開くボタンをクリックした場合", () => { + // sends openDiffEditor message + it("openDiffEditor メッセージが送信されること", async () => { + await setupWithDiffs([{ file: "src/main.ts", before: "before", after: "after", additions: 1, deletions: 1 }]); + + const mockPostMessage = vi.mocked(postMessage); + mockPostMessage.mockClear(); + const user = userEvent.setup(); + + // ヘッダーバーを展開 + await user.click(screen.getByTitle("File changes")); + + // 外部リンクボタンをクリック + const openButton = document.querySelector(".openButton")!; + await user.click(openButton); + + const diffCall = mockPostMessage.mock.calls.find((call) => call[0]?.type === "openDiffEditor"); + expect(diffCall).toBeTruthy(); + }); + }); + + // Multiple files with summary stats + context("複数ファイルの変更がある場合", () => { + beforeEach(async () => { + await setupWithDiffs([ + { file: "src/a.ts", before: "a", after: "aa", additions: 5, deletions: 2 }, + { file: "src/b.ts", before: "", after: "new", additions: 10, deletions: 0 }, + { file: "src/c.ts", before: "c", after: "", additions: 0, deletions: 8 }, + ]); + }); + + // Shows total file count + it("合計ファイル数が表示されること", () => { + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + // Shows all files when expanded + it("展開すると全ファイルが表示されること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("File changes")); + + expect(screen.getByText("a.ts")).toBeInTheDocument(); + expect(screen.getByText("b.ts")).toBeInTheDocument(); + expect(screen.getByText("c.ts")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/scenarios/16-session-fork.test.tsx b/webview/__tests__/scenarios/16-session-fork.test.tsx new file mode 100644 index 0000000..4ab6a57 --- /dev/null +++ b/webview/__tests__/scenarios/16-session-fork.test.tsx @@ -0,0 +1,78 @@ +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"; + +/** ユーザー→アシスタント→ユーザーの 3 メッセージ構成をセットアップする */ +async function setupConversation() { + renderApp(); + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg1 = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart1 = createTextPart("First question", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("First answer", { messageID: "m2" }); + const userMsg2 = createMessage({ id: "m3", sessionID: "s1", role: "user" }); + const userPart2 = createTextPart("Second question", { messageID: "m3" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg1, parts: [userPart1] }, + { info: assistantMsg, parts: [assistantPart] }, + { info: userMsg2, parts: [userPart2] }, + ], + }); + + vi.mocked(postMessage).mockClear(); + return session; +} + +// Session fork from checkpoint +describe("セッション Fork", () => { + beforeEach(async () => { + await setupConversation(); + }); + + // Fork button is displayed on checkpoint divider + context("チェックポイント区切り線の場合", () => { + // shows fork button + it("Fork ボタンが表示されること", () => { + expect(screen.getByText("Fork from here")).toBeInTheDocument(); + }); + + // shows retry button alongside fork button + it("Retry ボタンも表示されること", () => { + expect(screen.getByText("Retry from here")).toBeInTheDocument(); + }); + }); + + // Clicking fork button sends forkSession message + context("Fork ボタンをクリックした場合", () => { + // sends forkSession message with sessionId and messageId + it("forkSession メッセージを sessionId と messageId 付きで送信すること", async () => { + const user = userEvent.setup(); + const forkButton = screen.getByText("Fork from here").closest("button")!; + await user.click(forkButton); + expect(postMessage).toHaveBeenCalledWith({ + type: "forkSession", + sessionId: "s1", + messageId: "m3", + }); + }); + }); + + // After fork, new session becomes active + context("Fork 後に activeSession メッセージを受信した場合", () => { + // switches to the forked session + it("フォークされたセッションに切り替わること", async () => { + const forkedSession = createSession({ id: "s-forked", title: "Forked Chat" }); + await sendExtMessage({ type: "activeSession", session: forkedSession }); + expect(screen.getByText("Forked Chat")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/scenarios/17-child-session-nav.test.tsx b/webview/__tests__/scenarios/17-child-session-nav.test.tsx new file mode 100644 index 0000000..7fe9693 --- /dev/null +++ b/webview/__tests__/scenarios/17-child-session-nav.test.tsx @@ -0,0 +1,181 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createMessage, createSession, createSubtaskPart, createTaskToolPart, createTextPart } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** 親セッション + subtask パート + 子セッションをセットアップする */ +async function setupParentWithSubtask() { + renderApp(); + const parentSession = createSession({ id: "parent-1", title: "Parent Chat" }); + await sendExtMessage({ type: "activeSession", session: parentSession }); + + const assistantMsg = createMessage({ id: "m1", sessionID: "parent-1", role: "assistant" }); + const textPart = createTextPart("Let me delegate this.", { messageID: "m1" }); + const subtaskPart = createSubtaskPart("coder", "Fix the bug", { messageID: "m1" }); + + await sendExtMessage({ + type: "messages", + sessionId: "parent-1", + messages: [{ info: assistantMsg, parts: [textPart, subtaskPart as any] }], + }); + + const childSession = createSession({ id: "child-1", title: "Fix the bug", parentID: "parent-1" }); + await sendExtMessage({ type: "childSessions", sessionId: "parent-1", children: [childSession] }); + + vi.mocked(postMessage).mockClear(); + return { parentSession, childSession }; +} + +// Child session navigation +describe("子セッションナビゲーション", () => { + // Subtask part display in parent session + context("親セッションに subtask パートがある場合", () => { + beforeEach(async () => { + await setupParentWithSubtask(); + }); + + // displays the Agent label + it("Agent ラベルが表示されること", () => { + expect(screen.getByText("Agent")).toBeInTheDocument(); + }); + + // displays the agent name and description + it("エージェント名と説明が表示されること", () => { + expect(screen.getByText("coder: Fix the bug")).toBeInTheDocument(); + }); + }); + + // Clicking subtask to navigate to child session + context("subtask パートをクリックした場合", () => { + beforeEach(async () => { + await setupParentWithSubtask(); + }); + + // sends selectSession message for child session + it("子セッションの selectSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + const header = document.querySelector(".header:last-of-type") as HTMLElement; + await user.click(header); + expect(postMessage).toHaveBeenCalledWith({ + type: "selectSession", + sessionId: "child-1", + }); + }); + }); + + // Child session shows back button instead of session list toggle + context("子セッションを表示した場合", () => { + beforeEach(async () => { + renderApp(); + const childSession = createSession({ id: "child-1", title: "Fix the bug", parentID: "parent-1" }); + await sendExtMessage({ type: "activeSession", session: childSession }); + }); + + // shows back button + it("戻るボタンが表示されること", () => { + expect(screen.getByTitle("Back to parent session")).toBeInTheDocument(); + }); + + // does not show new chat button + it("新規チャットボタンが表示されないこと", () => { + expect(screen.queryByTitle("New chat")).not.toBeInTheDocument(); + }); + + // hides the input area + it("入力エリアが非表示になること", () => { + expect(screen.queryByPlaceholderText("Ask anything...")).not.toBeInTheDocument(); + }); + }); + + // Clicking back button navigates to parent + context("戻るボタンをクリックした場合", () => { + beforeEach(async () => { + renderApp(); + const childSession = createSession({ id: "child-1", title: "Fix the bug", parentID: "parent-1" }); + await sendExtMessage({ type: "activeSession", session: childSession }); + vi.mocked(postMessage).mockClear(); + }); + + // sends selectSession message for parent session + it("親セッションの selectSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Back to parent session")); + expect(postMessage).toHaveBeenCalledWith({ + type: "selectSession", + sessionId: "parent-1", + }); + }); + }); + + // Parent session does not show back button + context("親セッションの場合", () => { + beforeEach(async () => { + renderApp(); + const parentSession = createSession({ id: "parent-1", title: "Parent Chat" }); + await sendExtMessage({ type: "activeSession", session: parentSession }); + }); + + // does not show back button + it("戻るボタンが表示されないこと", () => { + expect(screen.queryByTitle("Back to parent session")).not.toBeInTheDocument(); + }); + + // shows new chat button + it("新規チャットボタンが表示されること", () => { + expect(screen.getByTitle("New chat")).toBeInTheDocument(); + }); + }); + + // task tool part rendered as subtask + context("task ツール呼び出しがメッセージに含まれる場合", () => { + beforeEach(async () => { + renderApp(); + const parentSession = createSession({ id: "parent-2", title: "Parent Chat 2" }); + await sendExtMessage({ type: "activeSession", session: parentSession }); + + const assistantMsg = createMessage({ id: "m2", sessionID: "parent-2", role: "assistant" }); + const textPart = createTextPart("Delegating to subagent.", { messageID: "m2" }); + const taskToolPart = createTaskToolPart("general", "Search for utils", { messageID: "m2" }); + + await sendExtMessage({ + type: "messages", + sessionId: "parent-2", + messages: [{ info: assistantMsg, parts: [textPart, taskToolPart as any] }], + }); + + const childSession = createSession({ + id: "child-task-1", + title: "Search for utils (@general subagent)", + parentID: "parent-2", + }); + await sendExtMessage({ type: "childSessions", sessionId: "parent-2", children: [childSession] }); + vi.mocked(postMessage).mockClear(); + }); + + // displays Agent label (not Run label) + it("Agent ラベルが表示されること(Run ではなく)", () => { + const agentLabels = screen.getAllByText("Agent"); + expect(agentLabels.length).toBeGreaterThan(0); + }); + + // displays agent name and description from state.input + it("エージェント名と説明が state.input から表示されること", () => { + expect(screen.getByText("general: Search for utils")).toBeInTheDocument(); + }); + + // navigates to child session on click + it("クリックで子セッションにナビゲートすること", async () => { + const user = userEvent.setup(); + // Find the subtask header showing "general: Search for utils" + const title = screen.getByText("general: Search for utils"); + const header = title.closest(".header") as HTMLElement; + await user.click(header); + expect(postMessage).toHaveBeenCalledWith({ + type: "selectSession", + sessionId: "child-task-1", + }); + }); + }); +}); diff --git a/webview/__tests__/scenarios/18-agent-mention.test.tsx b/webview/__tests__/scenarios/18-agent-mention.test.tsx new file mode 100644 index 0000000..6efe19a --- /dev/null +++ b/webview/__tests__/scenarios/18-agent-mention.test.tsx @@ -0,0 +1,199 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** テスト用エージェントデータ(subagent + primary を含む) */ +const testAgents = [ + { + name: "general", + description: "General purpose subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "explore", + description: "Read-only exploration subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "deny", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "build", + description: "Primary build agent", + mode: "primary", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "plan", + description: "Primary plan agent", + mode: "primary", + builtIn: true, + permission: { edit: "deny", bash: {} }, + tools: {}, + options: {}, + }, +] as any; + +/** エージェントメンションテスト用のセットアップ */ +async function setupWithAgents() { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + + // エージェント一覧を設定(subagent + primary を含む) + await sendExtMessage({ type: "agents", agents: testAgents }); + + vi.mocked(postMessage).mockClear(); +} + +// Agent mention +describe("エージェントメンション", () => { + // @ trigger shows agent popup + context("@ トリガーを入力した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // shows agent popup + it("エージェント候補ポップアップが表示されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + expect(screen.getByTestId("agent-popup")).toBeInTheDocument(); + }); + + // shows only subagent names in popup (not primary agents) + it("サブエージェントのみポップアップに表示されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + // subagent の general と explore が表示される + expect(screen.getByText("general")).toBeInTheDocument(); + expect(screen.getByText("explore")).toBeInTheDocument(); + // primary の build と plan は表示されない + expect(screen.queryByText("build")).not.toBeInTheDocument(); + expect(screen.queryByText("plan")).not.toBeInTheDocument(); + }); + + // filters agents by query + it("クエリでエージェントをフィルタすること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@gen"); + expect(screen.getByText("general")).toBeInTheDocument(); + expect(screen.queryByText("explore")).not.toBeInTheDocument(); + }); + }); + + // Selecting an agent + context("エージェントを選択した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // shows agent indicator + it("エージェントインジケーターが表示されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.click(screen.getByText("general")); + expect(screen.getByText("@general")).toBeInTheDocument(); + }); + + // closes the popup + it("ポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.click(screen.getByText("general")); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + }); + + // removes @ from text + it("テキストから @ が削除されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)") as HTMLTextAreaElement; + await user.type(textarea, "@"); + await user.click(screen.getByText("general")); + expect(textarea.value).toBe(""); + }); + }); + + // Sending with selected agent + context("エージェント選択後にメッセージを送信した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // includes agent in sendMessage + it("sendMessage に agent が含まれること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.click(screen.getByText("general")); + await user.type(textarea, "Fix the bug"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Fix the bug", + agent: "general", + }), + ); + }); + }); + + // Sending without agent + context("エージェント未選択でメッセージを送信した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // does not include agent in sendMessage + it("sendMessage に agent が含まれないこと", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Hello"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Hello", + }), + ); + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + agent: expect.anything(), + }), + ); + }); + }); + + // Escape closes popup + context("@ ポップアップ表示中に Escape を押した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + // closes the agent popup + it("エージェントポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + expect(screen.getByTestId("agent-popup")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/scenarios/19-session-share.test.tsx b/webview/__tests__/scenarios/19-session-share.test.tsx new file mode 100644 index 0000000..652d2e7 --- /dev/null +++ b/webview/__tests__/scenarios/19-session-share.test.tsx @@ -0,0 +1,121 @@ +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"; + +/** + * メッセージが存在するセッションをセットアップするヘルパー。 + * 空セッションでは共有ボタンが表示されないため、必ずメッセージを送信する。 + */ +async function setupSessionWithMessages(sessionOverrides: Partial[0]> = {}) { + const session = createSession({ id: "s1", title: "Chat", ...sessionOverrides }); + await sendExtMessage({ type: "activeSession", session }); + const msg = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const part = createTextPart("hello", { messageID: "m1" }); + await sendExtMessage({ type: "messages", sessionId: "s1", messages: [{ info: msg, parts: [part] }] }); + return session; +} + +// Session share +describe("セッション共有", () => { + // when session is active (not shared) + context("未共有のアクティブセッションがある場合", () => { + beforeEach(async () => { + renderApp(); + await setupSessionWithMessages(); + vi.mocked(postMessage).mockClear(); + }); + + // shows share button + it("共有ボタンが表示されること", () => { + expect(screen.getByTitle("Share session")).toBeInTheDocument(); + }); + + // sends shareSession message when clicked + it("共有ボタンクリック時に shareSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Share session")); + expect(postMessage).toHaveBeenCalledWith({ + type: "shareSession", + sessionId: "s1", + }); + }); + }); + + // when session becomes shared + context("セッションが共有状態になった場合", () => { + beforeEach(async () => { + renderApp(); + await setupSessionWithMessages({ share: { url: "https://share.example.com/abc" } }); + vi.mocked(postMessage).mockClear(); + }); + + // shows unshare button + it("共有解除ボタンが表示されること", () => { + expect(screen.getByTitle("Unshare session")).toBeInTheDocument(); + }); + + // share button is not visible + it("共有ボタンが表示されないこと", () => { + expect(screen.queryByTitle("Share session")).not.toBeInTheDocument(); + }); + + // sends unshareSession message when clicked + it("共有解除ボタンクリック時に unshareSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Unshare session")); + expect(postMessage).toHaveBeenCalledWith({ + type: "unshareSession", + sessionId: "s1", + }); + }); + }); + + // share → unshare flow + context("共有して共有解除するフローの場合", () => { + // share state toggles correctly + it("共有状態が正しく切り替わること", async () => { + renderApp(); + + // 1. メッセージ付きの未共有セッションを設定 + await setupSessionWithMessages(); + expect(screen.getByTitle("Share session")).toBeInTheDocument(); + + // 2. 共有状態のセッションに更新 + const sharedSession = createSession({ + id: "s1", + title: "Chat", + share: { url: "https://share.example.com/abc" }, + }); + await sendExtMessage({ type: "activeSession", session: sharedSession }); + expect(screen.getByTitle("Unshare session")).toBeInTheDocument(); + + // 3. 共有解除されたセッションに更新 + const unsharedSession = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session: unsharedSession }); + expect(screen.getByTitle("Share session")).toBeInTheDocument(); + }); + }); + + // when no active session + context("アクティブセッションがない場合", () => { + // does not show share button + it("共有ボタンが表示されないこと", () => { + renderApp(); + expect(screen.queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // when session has no messages (empty session) + context("メッセージのない空セッションの場合", () => { + // does not show share button + it("共有ボタンが表示されないこと", async () => { + renderApp(); + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + expect(screen.queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/scenarios/20-undo-redo.test.tsx b/webview/__tests__/scenarios/20-undo-redo.test.tsx new file mode 100644 index 0000000..ec3ffe5 --- /dev/null +++ b/webview/__tests__/scenarios/20-undo-redo.test.tsx @@ -0,0 +1,237 @@ +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"; + +/** ユーザー→アシスタントの 2 メッセージ構成をセットアップする */ +async function setupWithMessages() { + renderApp(); + const session = createSession({ id: "s1", title: "Undo Test" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart = createTextPart("Hello", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("Hi there", { messageID: "m2" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg, parts: [userPart] }, + { info: assistantMsg, parts: [assistantPart] }, + ], + }); + + vi.mocked(postMessage).mockClear(); + return session; +} + +/** ユーザー→アシスタント→ユーザーの 3 メッセージ構成をセットアップする */ +async function setupWithThreeMessages() { + renderApp(); + const session = createSession({ id: "s1", title: "Undo Test" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg1 = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart1 = createTextPart("First question", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("First answer", { messageID: "m2" }); + const userMsg2 = createMessage({ id: "m3", sessionID: "s1", role: "user" }); + const userPart2 = createTextPart("Second question", { messageID: "m3" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg1, parts: [userPart1] }, + { info: assistantMsg, parts: [assistantPart] }, + { info: userMsg2, parts: [userPart2] }, + ], + }); + + vi.mocked(postMessage).mockClear(); + return session; +} + +// Undo/Redo UI +describe("Undo/Redo", () => { + // Undo button is enabled when there are multiple messages + context("メッセージが 2 つ以上ある場合", () => { + beforeEach(async () => { + await setupWithMessages(); + }); + + // undo button is enabled + it("Undo ボタンが有効になること", () => { + const undoBtn = screen.getByTitle("Undo") as HTMLButtonElement; + expect(undoBtn.disabled).toBe(false); + }); + }); + + // Redo button is disabled when session has no revert field + context("Undo 未実行の場合", () => { + beforeEach(async () => { + await setupWithMessages(); + }); + + // redo button is disabled + it("Redo ボタンが disabled であること", () => { + const redoBtn = screen.getByTitle("Redo") as HTMLButtonElement; + expect(redoBtn.disabled).toBe(true); + }); + }); + + // Clicking undo sends undoSession message + context("Undo ボタンをクリックした場合", () => { + beforeEach(async () => { + await setupWithMessages(); + }); + + // sends undoSession message with last assistant message id + it("最後のアシスタントメッセージ ID で undoSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Undo")); + expect(postMessage).toHaveBeenCalledWith({ + type: "undoSession", + sessionId: "s1", + messageId: "m2", + }); + }); + }); + + // Undo prefills the input with the user message text + context("Undo でユーザーメッセージが巻き戻される場合", () => { + // prefills the input with the user text that was above the assistant message + it("入力欄にユーザーメッセージのテキストがプリフィルされること", async () => { + await setupWithThreeMessages(); + const user = userEvent.setup(); + await user.click(screen.getByTitle("Undo")); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + expect(textarea).toHaveValue("Second question"); + }); + }); + + // Undo with only user+assistant prefills user text + context("ユーザー+アシスタントの 2 メッセージで Undo した場合", () => { + // prefills the input with the user's message text + it("入力欄にユーザーメッセージのテキストがプリフィルされること", async () => { + await setupWithMessages(); + const user = userEvent.setup(); + await user.click(screen.getByTitle("Undo")); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + expect(textarea).toHaveValue("Hello"); + }); + }); + + // Redo button is enabled after undo (session.revert is set) + context("Undo 実行後の場合", () => { + // redo button is enabled when session has revert + it("Redo ボタンが有効になること", async () => { + renderApp(); + const session = createSession({ + id: "s1", + title: "Undo Test", + revert: { messageID: "m2" }, + }); + await sendExtMessage({ type: "activeSession", session }); + + const redoBtn = screen.getByTitle("Redo") as HTMLButtonElement; + expect(redoBtn.disabled).toBe(false); + }); + }); + + // Clicking redo sends redoSession message + context("Redo ボタンをクリックした場合", () => { + // sends redoSession message + it("redoSession メッセージを送信すること", async () => { + renderApp(); + const session = createSession({ + id: "s1", + title: "Redo Test", + revert: { messageID: "m2" }, + }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + const user = userEvent.setup(); + await user.click(screen.getByTitle("Redo")); + expect(postMessage).toHaveBeenCalledWith({ + type: "redoSession", + sessionId: "s1", + }); + }); + }); + + // Redo clears the prefill text + context("Undo 後に Redo した場合", () => { + // sends redoSession after undo + it("undoSession の後に redoSession を送信できること", async () => { + await setupWithThreeMessages(); + const user = userEvent.setup(); + + // Undo + await user.click(screen.getByTitle("Undo")); + + // session.revert を設定して Redo ボタンを有効にする + const sessionWithRevert = createSession({ + id: "s1", + title: "Undo Test", + revert: { messageID: "m2" }, + }); + await sendExtMessage({ type: "activeSession", session: sessionWithRevert }); + vi.mocked(postMessage).mockClear(); + + // Redo + await user.click(screen.getByTitle("Redo")); + expect(postMessage).toHaveBeenCalledWith({ + type: "redoSession", + sessionId: "s1", + }); + }); + }); + + // Undo/Redo buttons disabled when session is busy + context("セッションがビジーの場合", () => { + // undo button is disabled + it("Undo ボタンが disabled であること", async () => { + renderApp(); + const session = createSession({ id: "s1", title: "Busy Test" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart = createTextPart("Hello", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("Hi", { messageID: "m2" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg, parts: [userPart] }, + { info: assistantMsg, parts: [assistantPart] }, + ], + }); + + // ビジー状態にする: session.status イベントで busy を通知 + await sendExtMessage({ + type: "event", + event: { type: "session.status", properties: { status: { type: "busy" } } } as any, + }); + + const undoBtn = screen.getByTitle("Undo") as HTMLButtonElement; + expect(undoBtn.disabled).toBe(true); + }); + }); + + // No active session → no undo/redo buttons + context("アクティブセッションがない場合", () => { + // does not show undo/redo buttons + it("Undo/Redo ボタンが表示されないこと", () => { + renderApp(); + expect(screen.queryByTitle("Undo")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/__tests__/scenarios/21-popup-tab-select.test.tsx b/webview/__tests__/scenarios/21-popup-tab-select.test.tsx new file mode 100644 index 0000000..af3e5a5 --- /dev/null +++ b/webview/__tests__/scenarios/21-popup-tab-select.test.tsx @@ -0,0 +1,262 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** テスト用エージェントデータ */ +const testAgents = [ + { + name: "coder", + description: "Coding subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "explorer", + description: "Read-only subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "deny", bash: {} }, + tools: {}, + options: {}, + }, +] as any; + +/** ファイル候補付きセットアップ */ +async function setupWithFiles() { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + await sendExtMessage({ + type: "openEditors", + files: [ + { filePath: "src/main.ts", fileName: "main.ts" }, + { filePath: "src/utils.ts", fileName: "utils.ts" }, + ], + }); + await sendExtMessage({ + type: "workspaceFiles", + files: [ + { filePath: "src/main.ts", fileName: "main.ts" }, + { filePath: "src/utils.ts", fileName: "utils.ts" }, + { filePath: "src/config.ts", fileName: "config.ts" }, + ], + }); + await sendExtMessage({ type: "agents", agents: testAgents }); + vi.mocked(postMessage).mockClear(); +} + +// Tab selection in inline popups +describe("ポップアップの Tab 選択", () => { + beforeEach(async () => { + await setupWithFiles(); + }); + + // # popup: Tab moves focus through items + context("# ポップアップで Tab を押した場合", () => { + // first item gets focused + it("先頭のアイテムにフォーカスが当たること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + + // Tab again moves to second item + it("もう一度 Tab を押すと 2 番目のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{Tab}"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // # popup: Tab + Enter selects the focused item + context("# ポップアップで Tab でフォーカス後 Enter で確定した場合", () => { + // file is attached and popup closes + it("ファイルが添付されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{Tab}"); + await user.keyboard("{Enter}"); + // ポップアップが閉じる + expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy(); + // ファイルチップが表示される + expect(document.querySelectorAll(".chip").length).toBe(1); + }); + }); + + // # popup: ArrowDown moves focus + context("# ポップアップで ↓ キーを押した場合", () => { + // second item gets focused + it("次のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + + // pressing ArrowDown again moves to second item + it("もう一度 ↓ を押すと2番目のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // # popup: ArrowUp wraps to last item + context("# ポップアップで ↑ キーを押した場合", () => { + // wraps to last item + it("末尾のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowUp}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + const lastItem = items?.[items.length - 1]; + expect(lastItem?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // # popup: Enter on focused item selects it + context("# ポップアップでフォーカス済みアイテムで Enter を押した場合", () => { + // file is attached and popup closes + it("ファイルが添付されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + // ポップアップが閉じる + expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy(); + // ファイルチップが表示される + expect(document.querySelectorAll(".chip").length).toBe(1); + }); + }); + + // # popup: Enter without focus sends message + context("# ポップアップ表示中にフォーカスなしで Enter を押した場合", () => { + // sends the message (existing behavior) + it("メッセージが送信されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "hello #"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "hello #", + }), + ); + }); + }); + + // # popup: query change resets focus + context("# ポップアップでクエリを変更した場合", () => { + // focus resets + it("フォーカスがリセットされること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + // フォーカスが当たっている + let popup = document.querySelector("[data-testid='hash-popup']"); + expect(popup?.querySelector("[data-focused]")).toBeTruthy(); + // クエリを入力するとフォーカスリセット + await user.type(textarea, "m"); + popup = document.querySelector("[data-testid='hash-popup']"); + expect(popup?.querySelector("[data-focused]")).toBeFalsy(); + }); + }); + + // @ popup: Tab moves focus through agents + context("@ ポップアップで Tab を押した場合", () => { + // first agent gets focused + it("先頭のエージェントにフォーカスが当たること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='agent-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + + // Tab again moves to second agent + it("もう一度 Tab を押すと 2 番目のエージェントにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{Tab}"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='agent-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // @ popup: Tab + Enter selects the focused agent + context("@ ポップアップで Tab でフォーカス後 Enter で確定した場合", () => { + // agent is selected and popup closes + it("エージェントが選択されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{Tab}"); + await user.keyboard("{Enter}"); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + expect(screen.getByText("@coder")).toBeInTheDocument(); + }); + }); + + // @ popup: Enter on focused agent selects it + context("@ ポップアップでフォーカス済みエージェントで Enter を押した場合", () => { + // agent is selected + it("エージェントが選択されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + expect(screen.getByText("@coder")).toBeInTheDocument(); + }); + }); + + // @ popup: ArrowDown/ArrowUp navigation + context("@ ポップアップで ↓↑ キーで移動した場合", () => { + // ArrowDown then ArrowUp returns to first + it("↓ で 2 番目、↑ で 1 番目に戻ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + const popup = document.querySelector("[data-testid='agent-popup']"); + expect(popup?.querySelectorAll(":scope > div")[1]?.getAttribute("data-focused")).toBe("true"); + await user.keyboard("{ArrowUp}"); + expect(popup?.querySelectorAll(":scope > div")[0]?.getAttribute("data-focused")).toBe("true"); + }); + }); +}); 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/__tests__/utils/diff.test.ts b/webview/__tests__/utils/diff.test.ts new file mode 100644 index 0000000..2a954cd --- /dev/null +++ b/webview/__tests__/utils/diff.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { computeLineDiff, type DiffLine } 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..0ad97a0 --- /dev/null +++ b/webview/__tests__/utils/todo.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { parseTodos, type TodoItem } 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..97fc258 --- /dev/null +++ b/webview/__tests__/utils/tool-categories.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { CATEGORY_LABEL_KEYS, getCategory, TOOL_CATEGORIES, type ToolCategory } 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/ChatHeader.tsx b/webview/components/ChatHeader.tsx deleted file mode 100644 index 5a890fd..0000000 --- a/webview/components/ChatHeader.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Session } from "@opencode-ai/sdk"; -import { useLocale } from "../locales"; - -type Props = { - activeSession: Session | null; - onNewSession: () => void; - onToggleSessionList: () => void; -}; - -export function ChatHeader({ activeSession, onNewSession, onToggleSessionList }: Props) { - const t = useLocale(); - return ( -
- - - {activeSession?.title || t["header.title.fallback"]} - -
- -
-
- ); -} diff --git a/webview/components/EmptyState.tsx b/webview/components/EmptyState.tsx deleted file mode 100644 index 95eaf8a..0000000 --- a/webview/components/EmptyState.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useLocale } from "../locales"; - -type Props = { - onNewSession: () => void; -}; - -export function EmptyState({ onNewSession }: Props) { - const t = useLocale(); - return ( -
-
{t["empty.title"]}
-
- {t["empty.description"]} -
- -
- ); -} diff --git a/webview/components/InputArea.tsx b/webview/components/InputArea.tsx deleted file mode 100644 index 276675b..0000000 --- a/webview/components/InputArea.tsx +++ /dev/null @@ -1,415 +0,0 @@ -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 { LocaleSetting } from "../locales"; -import { ModelSelector } from "./ModelSelector"; -import { ContextIndicator } from "./ContextIndicator"; -import { ToolConfigPanel } from "./ToolConfigPanel"; -import type { McpStatus } from "@opencode-ai/sdk"; - -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]); - - // クリップボタンを押したときにエディタ一覧を取得してファイルピッカーを開く - 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)} - autoFocus - /> -
- {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 && ( - - )} -
- - {/* テキスト入力エリア(# ポップアップ付き) */} -
-