diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8e0804ca..382900c0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,43 +1,68 @@ ---- -name: Bug 反馈 -about: 创建一个 Bug 报告以帮助 cc-haha 项目改进 -title: '[BUG] ' -labels: bug -assignees: '' ---- +--- +name: Bug 反馈 +about: 提交桌面端或 CLI 的 Bug 报告 +title: '[BUG] ' +labels: bug +assignees: '' +--- -## 🔍 问题检查清单 - +## 提交前确认 + -- [ ] 我已经仔细阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题) 部分 -- [ ] 我已经搜索并查看了[已关闭的 issues](https://github.com/NanmiCoder/cc-haha/issues?q=is%3Aissue+is%3Aclosed) -- [ ] 我确认这不是由于 API Key 配置错误、API 端点不兼容、Bun 版本过低等常见原因导致的问题 +- [ ] 我已经阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题) +- [ ] 我已经升级到最新版本后复现过这个问题 +- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题 +- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息 -## 🐛 问题描述 - +## 问题描述 + -## 📝 复现步骤 -1. -2. -3. +## 运行环境 + -## 💻 运行环境 -- 操作系统: -- Bun 版本 (`bun --version`): -- Node 版本 (`node --version`): -- API 提供商 (如 MiniMax / OpenRouter / Anthropic 官方等): -- 使用的模型: -- 启动方式 (TUI / --print / Recovery CLI): +- 使用方式: 桌面端 / CLI / 其他: +- 操作系统: Windows / macOS / Linux: +- 系统版本: 例如 Windows 11 23H2 / macOS 15.4: +- 桌面端版本: 例如 v0.1.8(设置页 -> 关于 中可查看): +- 安装来源: GitHub Release / 源码本地构建 / 其他: +- Bun 版本(CLI 问题填写,`bun --version`): +- Node 版本(CLI 问题填写,`node --version`): -## 📋 错误日志 - -```shell -在此粘贴错误日志 +## Provider / 模型配置 + + +- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama: +- Base URL: +- 使用的模型: +- 相关配置截图: + +## 复现步骤 + + +1. +2. +3. + +是否稳定复现: 是 / 否 / 不确定 + +## 错误信息与日志 + + +```shell +在此粘贴错误信息和日志 ``` -## 📷 错误截图 - +## 截图或录屏 + + + +## 补充信息 + + --- diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 543b1f54..377a2f4e 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,39 +1,62 @@ ---- -name: 使用问题咨询 -about: 提交使用过程中遇到的问题 -title: '[问题] ' -labels: question -assignees: '' ---- +--- +name: 使用问题咨询 +about: 咨询桌面端或 CLI 使用过程中遇到的问题 +title: '[问题] ' +labels: question +assignees: '' +--- -## ⚠️ 提交前确认 - -- [ ] 我已经仔细阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题) 部分 -- [ ] 我已经阅读了[第三方模型使用指南](https://github.com/NanmiCoder/cc-haha/blob/main/docs/guide/third-party-models.md) -- [ ] 我已经搜索并查看了[已关闭的 issues](https://github.com/NanmiCoder/cc-haha/issues?q=is%3Aissue+is%3Aclosed) +## 提交前确认 + -## ❓ 问题描述 - +- [ ] 我已经阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题) +- [ ] 我已经阅读了[第三方模型使用指南](https://github.com/NanmiCoder/cc-haha/blob/main/docs/guide/third-party-models.md)(如使用第三方 provider) +- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题 +- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息 -## 🔍 使用场景 - -- 使用功能: (如:TUI 交互 / --print 无头模式 / MCP 服务器 / Skills 等) -- API 提供商: (如:MiniMax / OpenRouter / Anthropic 官方 / LiteLLM 代理等) +## 问题描述 + -## 💻 环境信息 -- 操作系统: -- Bun 版本 (`bun --version`): -- Node 版本 (`node --version`): -- API 提供商: -- 使用的模型: -## 📋 错误日志 -```shell -在此粘贴完整的错误日志 +## 使用环境 + + +- 使用方式: 桌面端 / CLI / 其他: +- 操作系统: Windows / macOS / Linux: +- 系统版本: 例如 Windows 11 23H2 / macOS 15.4: +- 桌面端版本: 例如 v0.1.8(设置页 -> 关于 中可查看): +- 安装来源: GitHub Release / 源码本地构建 / 其他: +- Bun 版本(CLI 问题填写,`bun --version`): +- Node 版本(CLI 问题填写,`node --version`): + +## 使用场景 + + +- 使用功能: 例如会话对话 / Provider 配置 / 模型调用 / MCP / Skills / 远程控制: +- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama: +- 使用的模型: + +## 配置截图 + + + +## 操作步骤 + + +1. +2. +3. + +## 错误信息与日志 + + +```shell +在此粘贴错误信息和日志 ``` -## 📷 错误截图 - +## 截图或录屏 + + --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..98a1335e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Summary + + +## Verification + +- [ ] I ran the relevant local checks, or explained why they do not apply. + +## Risk + +- [ ] This PR does not touch CLI core paths, or it has maintainer approval for `allow-cli-core-change`. +- [ ] User-facing behavior changes include tests or an explanation of why tests are not practical. + +@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact. diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 8a0ac379..517bb161 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -33,7 +33,7 @@ jobs: steps: - id: set-matrix run: | - ALL='[{"platform":"macos-latest","rust_target":"aarch64-apple-darwin","tauri_args":"--target aarch64-apple-darwin","bundles":"app,dmg","label":"macOS-ARM64","artifact_name":"macos-arm64"},{"platform":"macos-latest","rust_target":"x86_64-apple-darwin","tauri_args":"--target x86_64-apple-darwin","bundles":"app,dmg","label":"macOS-x64","artifact_name":"macos-x64"},{"platform":"windows-latest","rust_target":"x86_64-pc-windows-msvc","tauri_args":"","bundles":"nsis,msi","label":"Windows-x64","artifact_name":"windows-x64"},{"platform":"ubuntu-22.04","rust_target":"x86_64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-x64","artifact_name":"linux-x64"},{"platform":"ubuntu-22.04-arm","rust_target":"aarch64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-ARM64","artifact_name":"linux-arm64"}]' + ALL='[{"platform":"macos-latest","rust_target":"aarch64-apple-darwin","tauri_args":"--target aarch64-apple-darwin","bundles":"app,dmg","label":"macOS-ARM64","artifact_name":"macos-arm64","asset_platform":"macos","asset_arch":"arm64"},{"platform":"macos-latest","rust_target":"x86_64-apple-darwin","tauri_args":"--target x86_64-apple-darwin","bundles":"app,dmg","label":"macOS-x64","artifact_name":"macos-x64","asset_platform":"macos","asset_arch":"x64"},{"platform":"windows-latest","rust_target":"x86_64-pc-windows-msvc","tauri_args":"","bundles":"msi","label":"Windows-x64","artifact_name":"windows-x64","asset_platform":"windows","asset_arch":"x64"},{"platform":"ubuntu-22.04","rust_target":"x86_64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-x64","artifact_name":"linux-x64","asset_platform":"linux","asset_arch":"x64"},{"platform":"ubuntu-22.04-arm","rust_target":"aarch64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-ARM64","artifact_name":"linux-arm64","asset_platform":"linux","asset_arch":"arm64"}]' SELECTED="${{ inputs.platforms }}" if [ "$SELECTED" = "all" ]; then @@ -169,12 +169,47 @@ jobs: if [ -d "$SEARCH_DIR" ]; then find "$SEARCH_DIR" \ \( -type f \( \ - -name "*.dmg" -o -name "*.exe" -o -name "*.msi" -o \ + -name "*.dmg" -o -name "*.msi" -o \ -name "*.deb" -o -name "*.AppImage" -o -name "*.zip" \ \) \) -o \ \( -type d -name "*.app" \) | while read -r f; do - echo " Copying: $(basename "$f")" - cp -R "$f" "$STAGING/" + base="$(basename "$f")" + bundle="artifact" + ext="" + + case "$base" in + *.app) + bundle="app" + ext=".app" + ;; + *.dmg) + bundle="dmg" + ext=".dmg" + ;; + *.msi) + bundle="msi" + ext=".msi" + ;; + *.deb) + bundle="deb" + ext=".deb" + ;; + *.AppImage) + bundle="appimage" + ext=".AppImage" + ;; + *.zip) + bundle="zip" + ext=".zip" + ;; + *) + ext="" + ;; + esac + + target="Claude-Code-Haha_${{ steps.version.outputs.version }}_${{ matrix.asset_platform }}_${{ matrix.asset_arch }}_${bundle}${ext}" + echo " Copying: ${base} -> ${target}" + cp -R "$f" "$STAGING/$target" done fi diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 00000000..71506c9d --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,181 @@ +name: PR Quality + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-quality-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + change-policy: + name: change-policy + runs-on: ubuntu-latest + outputs: + areas: ${{ steps.policy.outputs.areas }} + area_labels: ${{ steps.policy.outputs.area_labels }} + blocked: ${{ steps.policy.outputs.blocked }} + desktop_checks: ${{ steps.policy.outputs.desktop_checks }} + server_checks: ${{ steps.policy.outputs.server_checks }} + adapter_checks: ${{ steps.policy.outputs.adapter_checks }} + desktop_native_checks: ${{ steps.policy.outputs.desktop_native_checks }} + docs_checks: ${{ steps.policy.outputs.docs_checks }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Run policy tests + run: bun run check:policy + + - name: Collect changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' > changed-files.txt + jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH" > labels.txt + + - name: Evaluate change policy + id: policy + run: bun run scripts/pr/change-policy.ts --files changed-files.txt --labels-file labels.txt + + desktop-checks: + name: desktop-checks + needs: change-policy + if: needs.change-policy.outputs.desktop_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install root dependencies + run: bun install + + - name: Install desktop dependencies + working-directory: desktop + run: bun install + + - name: Run desktop checks + run: bun run check:desktop + + server-checks: + name: server-checks + needs: change-policy + if: needs.change-policy.outputs.server_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install root dependencies + run: bun install + + - name: Run server checks + run: bun run check:server + + adapter-checks: + name: adapter-checks + needs: change-policy + if: needs.change-policy.outputs.adapter_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install adapter dependencies + working-directory: adapters + run: bun install + + - name: Run adapter checks + run: bun run check:adapters + + desktop-native-checks: + name: desktop-native-checks + needs: change-policy + if: needs.change-policy.outputs.desktop_native_checks == 'true' + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential curl wget file \ + libxdo-dev libssl-dev \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev patchelf \ + libfuse2 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: 'desktop/src-tauri -> target' + + - name: Install root dependencies + run: bun install + + - name: Install desktop dependencies + working-directory: desktop + run: bun install + + - name: Install adapter dependencies + working-directory: adapters + run: bun install + + - name: Run desktop native checks + run: bun run check:native + + docs-checks: + name: docs-checks + needs: change-policy + if: needs.change-policy.outputs.docs_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Run docs checks + run: npm ci && npm run docs:build diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 00000000..bf94fc0d --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -0,0 +1,259 @@ +name: PR Triage + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: pr-triage-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + triage: + name: label-and-summarize + runs-on: ubuntu-latest + steps: + - name: Apply deterministic PR labels and summary + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner + const repo = context.repo.repo + const issue_number = context.payload.pull_request.number + + const managedLabels = { + 'area:desktop': '1d76db', + 'area:server': '0e8a16', + 'area:adapters': '5319e7', + 'area:docs': '0075ca', + 'area:release': 'fbca04', + 'area:cli-core': 'b60205', + 'needs-maintainer-approval': 'b60205', + 'allow-cli-core-change': 'c2e0c6', + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: issue_number, + per_page: 100, + }) + const filenames = files.map((file) => file.filename) + const currentLabels = new Set((context.payload.pull_request.labels || []).map((label) => label.name)) + + const areas = new Set() + const cliCoreFiles = [] + const cliPrefixes = [ + 'bin/', + 'src/entrypoints/', + 'src/screens/', + 'src/components/', + 'src/commands/', + 'src/tools/', + 'src/utils/', + ] + const docsExact = new Set(['README.md', 'README.en.md', 'package.json', 'package-lock.json', '.github/workflows/deploy-docs.yml']) + const releaseExact = new Set([ + '.github/workflows/pr-quality.yml', + '.github/workflows/pr-triage.yml', + '.github/workflows/release-desktop.yml', + '.github/workflows/build-desktop-dev.yml', + 'scripts/pr/change-policy.ts', + 'scripts/pr/change-policy.test.ts', + 'scripts/pr/check-pr.ts', + 'scripts/pr/run-server-tests.ts', + 'scripts/release.ts', + 'desktop/src-tauri/tauri.conf.json', + 'desktop/src-tauri/Cargo.toml', + 'desktop/src-tauri/Cargo.lock', + ]) + + for (const filename of filenames) { + if (filename.startsWith('desktop/')) areas.add('area:desktop') + if (filename.startsWith('src/server/')) areas.add('area:server') + if (filename.startsWith('adapters/')) areas.add('area:adapters') + if (filename.startsWith('docs/') || filename.startsWith('release-notes/') || docsExact.has(filename)) areas.add('area:docs') + if (releaseExact.has(filename)) areas.add('area:release') + if (cliPrefixes.some((prefix) => filename.startsWith(prefix))) { + areas.add('area:cli-core') + cliCoreFiles.push(filename) + } + } + + if (cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')) { + areas.add('needs-maintainer-approval') + } + + const hasTest = (prefix) => filenames.some((file) => + file.startsWith(prefix) && (/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/')) + ) + const changedProduct = (predicate) => filenames.filter((file) => + predicate(file) && + !/\.test\.[cm]?[jt]sx?$/.test(file) && + !file.includes('/__tests__/') && + !file.includes('/fixtures/') + ) + const desktopProduct = changedProduct((file) => file.startsWith('desktop/src/')) + const serverProduct = changedProduct((file) => file.startsWith('src/server/')) + const adapterProduct = changedProduct((file) => file.startsWith('adapters/')) + const agentRuntimeProduct = changedProduct((file) => + file.startsWith('src/server/ws/') || + file.startsWith('src/server/services/conversation') || + file.startsWith('src/tools/') || + file.startsWith('src/utils/') + ) + + const requiredChecks = ['change-policy'] + if (areas.has('area:desktop') || areas.has('area:server')) requiredChecks.push('desktop-checks') + if (areas.has('area:server') || filenames.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) requiredChecks.push('server-checks') + if (areas.has('area:adapters')) requiredChecks.push('adapter-checks') + if ( + filenames.some((file) => + file.startsWith('desktop/') || + file.startsWith('adapters/') || + file.startsWith('src/server/') || + ['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/src-tauri/Cargo.lock', 'desktop/src-tauri/Cargo.toml', 'desktop/src-tauri/tauri.conf.json'].includes(file) + ) + ) requiredChecks.push('desktop-native-checks') + if (areas.has('area:docs')) requiredChecks.push('docs-checks') + + const testSignals = [] + if (desktopProduct.length > 0 && !hasTest('desktop/src/')) { + testSignals.push('Desktop product files changed without a desktop test file in the PR.') + } + if (serverProduct.length > 0 && !hasTest('src/server/')) { + testSignals.push('Server product files changed without a server test file in the PR.') + } + if (adapterProduct.length > 0 && !hasTest('adapters/')) { + testSignals.push('Adapter product files changed without an adapter test file in the PR.') + } + if (agentRuntimeProduct.length > 0) { + testSignals.push('Agent/model runtime path changed: use mock/request-shape tests in PR and maintainer live-model smoke before release.') + } + if (testSignals.length === 0) { + testSignals.push('No obvious missing-test signal from changed paths.') + } + + const riskNotes = [] + if (filenames.some((file) => file.startsWith('desktop/src-tauri/'))) { + riskNotes.push('Tauri/native code changed: inspect sidecar build and cargo check.') + } + if (filenames.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) { + riskNotes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.') + } + if (filenames.some((file) => file.startsWith('src/server/ws/') || file.startsWith('src/server/services/conversation'))) { + riskNotes.push('Session runtime changed: review reconnect, startup diagnostics, provider selection, and thinking settings.') + } + if (filenames.some((file) => file.includes('provider') || file.includes('WebSearchTool'))) { + riskNotes.push('Provider/search behavior changed: PR gate uses mock tests; live-provider tests stay maintainer-only.') + } + if (filenames.some((file) => file.startsWith('.github/workflows/') || file.startsWith('scripts/pr/'))) { + riskNotes.push('CI/policy changed: inspect workflow behavior itself, not just application tests.') + } + if (riskNotes.length === 0) { + riskNotes.push('No special risk notes from changed paths.') + } + + for (const [name, color] of Object.entries(managedLabels)) { + try { + await github.rest.issues.createLabel({ + owner, + repo, + name, + color, + }) + } catch (error) { + if (error.status !== 422) throw error + } + } + + const desiredLabels = [...areas] + if (desiredLabels.length > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: desiredLabels, + }) + } + + for (const label of Object.keys(managedLabels)) { + if (label === 'allow-cli-core-change') continue + if (!areas.has(label) && currentLabels.has(label)) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: label, + }) + } catch (error) { + if (error.status !== 404) throw error + } + } + } + + const marker = '' + const areaText = [...areas].filter((label) => label.startsWith('area:')).sort().join(', ') || 'none' + const approvalText = cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change') + ? 'Blocked by policy until a maintainer applies `allow-cli-core-change` and approves the PR.' + : 'No CLI-core policy block detected.' + const cliFilesText = cliCoreFiles.length + ? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n') + : '- none' + + const body = [ + marker, + '## PR quality triage', + '', + `**Changed areas:** ${areaText}`, + '', + `**CLI core policy:** ${approvalText}`, + '', + '**CLI core files:**', + cliFilesText, + '', + '**Expected checks:**', + requiredChecks.map((check) => `- \`${check}\``).join('\n'), + '', + '**Test coverage signals:**', + testSignals.map((signal) => `- ${signal}`).join('\n'), + '', + '**Risk notes:**', + riskNotes.map((note) => `- ${note}`).join('\n'), + '', + 'Hard merge gates still come from GitHub Actions, not AI review.', + '', + '**Dosu handoff:** Dosu can be used as the AI reviewer for risk explanation, missing-test prompts, and maintainer Q&A. If it does not comment automatically from the PR template, ask:', + '', + '@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.', + ].join('\n') + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }) + const existing = comments.find((comment) => comment.body && comment.body.includes(marker)) + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }) + } diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6603eef0..f8bc8a65 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -29,30 +29,35 @@ jobs: rust_target: aarch64-apple-darwin tauri_args: '--target aarch64-apple-darwin' label: macOS-ARM64 + asset_name_pattern: 'Claude-Code-Haha_[version]_macos_arm64_[bundle][ext]' # macOS Intel - platform: macos-latest rust_target: x86_64-apple-darwin tauri_args: '--target x86_64-apple-darwin' label: macOS-x64 + asset_name_pattern: 'Claude-Code-Haha_[version]_macos_x64_[bundle][ext]' # Linux x64 - platform: ubuntu-22.04 rust_target: x86_64-unknown-linux-gnu tauri_args: '--bundles deb' label: Linux-x64 + asset_name_pattern: 'Claude-Code-Haha_[version]_linux_x64_[bundle][ext]' # Linux ARM64 - platform: ubuntu-22.04-arm rust_target: aarch64-unknown-linux-gnu tauri_args: '--bundles deb' label: Linux-ARM64 + asset_name_pattern: 'Claude-Code-Haha_[version]_linux_arm64_[bundle][ext]' # Windows x64 - platform: windows-latest rust_target: x86_64-pc-windows-msvc - tauri_args: '' + tauri_args: '--bundles nsis' label: Windows-x64 + asset_name_pattern: 'Claude-Code-Haha_[version]_windows_x64_[bundle][ext]' runs-on: ${{ matrix.platform }} name: Build (${{ matrix.label }}) @@ -171,4 +176,6 @@ jobs: releaseBody: ${{ steps.release_notes.outputs.body }} releaseDraft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }} prerelease: false + updaterJsonPreferNsis: true + releaseAssetNamePattern: ${{ matrix.asset_name_pattern }} args: ${{ matrix.tauri_args }} --config src-tauri/tauri.release-ci.json diff --git a/.gitignore b/.gitignore index a55d69d2..7160cc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ extracted-natives/ # E2E screenshots e2e-*.png +# Quality gate reports +artifacts/quality-runs/ + # Desktop build output desktop/dist/ desktop/build-artifacts/ @@ -39,6 +42,7 @@ desktop/brand-assets/ # Superpowers plans & specs (local only) docs/superpowers/ +.superpowers/ # Temp server/web logs .tmp-*.log @@ -62,4 +66,10 @@ sidebar-snapshot.md # VitePress docs/.vitepress/dist docs/.vitepress/cache -runtime/__pycache__ \ No newline at end of file +runtime/__pycache__ + +# IDE +.idea + +# Codex logs +.codex-logs/ diff --git a/AGENTS.md b/AGENTS.md index fbce778b..c747a369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,10 @@ Install root dependencies with `bun install`, then install desktop dependencies - `cd desktop && bun run build`: type-check and produce a production web build. - `cd desktop && bun run test`: run Vitest suites. - `cd desktop && bun run lint`: run TypeScript no-emit checks. +- `bun run quality:providers`: list configured provider/model selectors for live agent baselines. +- `bun run quality:pr`: run the local PR quality gate and write a report under `artifacts/quality-runs/`. +- `bun run quality:gate --mode baseline --allow-live --provider-model `: run live Coding Agent baseline cases, including desktop agent-browser smoke. +- `bun run quality:gate --mode release --allow-live --provider-model `: run the release gate with live baseline coverage. ## Desktop Release Workflow - Desktop releases are built remotely by GitHub Actions, not by uploading local build artifacts. @@ -32,5 +36,23 @@ Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match ## Testing Guidelines Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. No coverage gate is configured, so add regression tests for any behavior you change and run the relevant suites before opening a PR. +## Quality Gate Automation +Future Coding Agents should run the right local gate themselves before claiming a change is ready. Do not ask the user to manually run the commands unless credentials, local model access, or machine resources are missing. + +- For normal code changes, run the narrow relevant check first, then `bun run quality:pr` before a PR-ready or merge-ready claim. +- Use `bun run check:server` for `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, or API behavior changes. +- Use `bun run check:desktop` for `desktop/src` UI, stores, API clients, and desktop web behavior changes. +- Use `bun run check:native` for `desktop/src-tauri`, sidecars, native packaging, release, or platform startup behavior changes. +- Use `bun run check:adapters` for `adapters/`; on a fresh checkout run `cd adapters && bun install` first if dependencies are missing. +- Use `bun run check:docs` for docs, VitePress, README, or docs workflow changes. +- For chat, agent loop, tool execution, provider routing, desktop chat UI, CLI task execution, or other core Coding Agent paths, also run a live baseline when local providers are available: first `bun run quality:providers`, then choose one or more copyable selectors and run `bun run quality:gate --mode baseline --allow-live --provider-model `. +- For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model ` with at least one real provider/model selector. Prefer multiple providers when quota is available. +- If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-baseline blocker explicitly instead of claiming full release confidence. +- `bun run check:docs` executes `npm ci`, which can rebuild root `node_modules`. Run docs checks sequentially, not in parallel with `quality:pr`, `check:native`, or other commands that depend on the same installed packages. +- Quality reports are written to `artifacts/quality-runs//`. Summarize the final report path and the pass/fail counts in handoffs and PR descriptions. +- Do not commit generated `artifacts/quality-runs/`, local `.omx/` state, `node_modules/`, `desktop/node_modules/`, or adapter dependency folders. +- Do not claim "complete", "ready to merge", or "ready to release" without either running the matching gate or naming the exact blocker that prevented it. + ## Commit & Pull Request Guidelines Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `docs:`. Keep subjects imperative and scoped to one change. PRs should explain the user-visible impact, list verification steps, link related issues, and include screenshots for desktop or docs UI changes. Keep diffs reviewable and call out any follow-up work or known gaps. +Branch names should use normal product prefixes such as `fix/xxx`, `feat/xxx`, or `docs/xxx`; do not create `codex/`-prefixed branches in this repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d8991d94 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +Thanks for helping improve Claude Code Haha. + +For the full contributor guide, including local checks, live model baselines, quality-gate reports, and PR expectations, see: + +- Chinese: [docs/guide/contributing.md](docs/guide/contributing.md) +- English: [docs/en/guide/contributing.md](docs/en/guide/contributing.md) + +Most contributors should run this before opening a PR: + +```bash +bun install +bun run quality:pr +``` + +If you run adapter or native checks on a fresh clone, install adapter dependencies too: + +```bash +cd adapters +bun install +``` + +If your change touches the desktop chat path, provider/runtime selection, CLI bridge, permissions, tools, file editing, or release packaging, also run a live baseline with your own local model provider: + +```bash +bun run quality:providers +bun run quality:gate --mode baseline --allow-live --provider-model :main +``` + +Quality reports are written under `artifacts/quality-runs//`. diff --git a/README.en.md b/README.en.md index c77565f2..48cc93ae 100644 --- a/README.en.md +++ b/README.en.md @@ -195,6 +195,7 @@ http://127.0.0.1:2024 |------|------| | [Environment Variables](docs/en/guide/env-vars.md) | Full env var reference and configuration methods | | [Third-Party Models](docs/en/guide/third-party-models.md) | Using OpenAI / DeepSeek / Ollama and other non-Anthropic models | +| [Contributing](docs/en/guide/contributing.md) | Local tests, live model baselines, PR gates, and release gates | | [Memory System](docs/memory/01-usage-guide.md) | Cross-session persistent memory usage and implementation | | [Multi-Agent System](docs/agent/01-usage-guide.md) | Agent orchestration, parallel tasks and Teams collaboration | | [Skills System](docs/skills/01-usage-guide.md) | Extensible capability plugins, custom workflows and conditional activation | @@ -212,6 +213,38 @@ http://127.0.0.1:2024 This project is maintained in the author's spare time. Corporate or individual sponsorships are welcome to support ongoing development. Custom features, integrations, and business partnerships are also open for discussion. + + + + + + + + + + + + + + + + + +
SponsorDescription
+ + JieKou AI
+ 接口AI +
+
+ Thanks to JieKou AI for sponsoring this project. JieKou AI provides official model resources with stable, high-performance API access. Subscription bundles are priced at 20% off the official rate; new users who register through this link and bind GitHub can claim a $3 coupon. +
+ + ShengSuanYun + + + Thanks to ShengSuanYun for sponsoring this project. ShengSuanYun is an industrial-grade AI task parallel execution platform for AI Native Teams, aggregating Claude, ChatGPT, Gemini, and other LLM, image, and video model capacity through direct, non-reverse-engineered access. Its platform SLA reaches 99.7%, with service status available online. It also supports dedicated enterprise gateways, cost and permission controls, smart routing, security protection, BYOK, usage-based billing, upcoming tokens plans, and invoicing. New users registering through this link can receive 10 yuan in model credits plus a 10% first top-up bonus. +
+ 📧 **Contact**: relakkes@gmail.com --- @@ -241,6 +274,30 @@ If this project helps you, consider buying me a coffee — every bit of support --- +## Thanks + +Thanks to the following open-source projects and community practices for reference and inspiration: + +- [React](https://github.com/facebook/react): frontend engineering and component-based UI ecosystem. +- [Tauri](https://github.com/tauri-apps/tauri): cross-platform desktop app capabilities and engineering practices. +- [cc-switch](https://github.com/farion1231/cc-switch): reference for model provider configuration. + +--- + +## ⭐ Star History + +If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha. + + + + + + Star History Chart + + + +--- + ## Disclaimer This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only. diff --git a/README.md b/README.md index 97b930cd..a778d439 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Claude Code Haha

- Claude Code Haha + Claude Code Haha

@@ -195,6 +195,7 @@ http://127.0.0.1:2024 |------|------| | [环境变量](docs/guide/env-vars.md) | 完整环境变量参考和配置方式 | | [第三方模型](docs/guide/third-party-models.md) | 接入 OpenAI / DeepSeek / Ollama 等非 Anthropic 模型 | +| [贡献与质量门禁](docs/guide/contributing.md) | 本地测试、真实模型 baseline、PR 和 release 门禁 | | [记忆系统](docs/memory/01-usage-guide.md) | 跨会话持久化记忆的使用与实现 | | [多 Agent 系统](docs/agent/01-usage-guide.md) | 多代理编排、并行任务执行与 Teams 协作 | | [Skills 系统](docs/skills/01-usage-guide.md) | 可扩展能力插件、自定义工作流与条件激活 | @@ -212,6 +213,38 @@ http://127.0.0.1:2024 本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。 + + + + + + + + + + + + + + + + + +
赞助商介绍
+ + 接口AI
+ 接口AI +
+
+ 感谢 接口AI 赞助本项目!接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 专属链接 注册并绑定 GitHub,可领取 3 美元优惠券。 +
+ + 胜算云 + + + 感谢 胜算云 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 服务状态。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 专属链接 注册可获 10 元模力及首充 10% 赠送。 +
+ 📧 **联系邮箱**:relakkes@gmail.com --- @@ -241,6 +274,30 @@ http://127.0.0.1:2024 --- +## 感谢 + +感谢以下开源项目和社区实践为本项目提供参考与启发: + +- [React](https://github.com/facebook/react):前端工程与组件化 UI 生态。 +- [Tauri](https://github.com/tauri-apps/tauri):跨端桌面应用能力与工程实践。 +- [cc-switch](https://github.com/farion1231/cc-switch):模型供应商配置能力参考。 + +--- + +## ⭐ Star 趋势图 + +如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha! + + + + + + Star History Chart + + + +--- + ## Disclaimer 本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。 diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 10446003..f10606f8 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -24,6 +24,8 @@ export type SessionTask = { export class AdapterHttpClient { readonly httpBaseUrl: string + /** Default timeout for HTTP requests (30 seconds) */ + private static readonly DEFAULT_TIMEOUT_MS = 30_000 constructor(wsUrl: string) { this.httpBaseUrl = wsUrl @@ -32,27 +34,50 @@ export class AdapterHttpClient { .replace(/\/$/, '') } + /** Create an AbortController with timeout */ + private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): { + controller: AbortController + timer: ReturnType + } { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + return { controller, timer } + } + async createSession(workDir: string): Promise { - const res = await fetch(`${this.httpBaseUrl}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workDir }), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })) - throw new Error(`Failed to create session: ${(err as any).message}`) + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir }), + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to create session: ${(err as any).message}`) + } + const data = (await res.json()) as { sessionId: string } + return data.sessionId + } finally { + clearTimeout(timer) } - const data = (await res.json()) as { sessionId: string } - return data.sessionId } async listRecentProjects(): Promise { - const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`) - if (!res.ok) { - throw new Error(`Failed to list projects: ${res.statusText}`) + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, { + signal: controller.signal, + }) + if (!res.ok) { + throw new Error(`Failed to list projects: ${res.statusText}`) + } + const data = (await res.json()) as { projects: RecentProject[] } + return data.projects + } finally { + clearTimeout(timer) } - const data = (await res.json()) as { projects: RecentProject[] } - return data.projects } /** @@ -86,22 +111,36 @@ export class AdapterHttpClient { } async getGitInfo(sessionId: string): Promise { - const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`) - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })) - throw new Error(`Failed to load git info: ${(err as any).message}`) + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to load git info: ${(err as any).message}`) + } + return (await res.json()) as GitInfo + } finally { + clearTimeout(timer) } - return (await res.json()) as GitInfo } async getTasksForSession(sessionId: string): Promise { - const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`) - if (!res.ok) { - if (res.status === 404) return [] - const err = await res.json().catch(() => ({ message: res.statusText })) - throw new Error(`Failed to load tasks: ${(err as any).message}`) + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, { + signal: controller.signal, + }) + if (!res.ok) { + if (res.status === 404) return [] + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to load tasks: ${(err as any).message}`) + } + const data = (await res.json()) as { tasks?: SessionTask[] } + return Array.isArray(data.tasks) ? data.tasks : [] + } finally { + clearTimeout(timer) } - const data = (await res.json()) as { tasks?: SessionTask[] } - return Array.isArray(data.tasks) ? data.tasks : [] } } diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts index 670b41fb..4cd8259f 100644 --- a/adapters/common/pairing.ts +++ b/adapters/common/pairing.ts @@ -58,9 +58,9 @@ function readConfigFile(): Record { function writeConfigFile(data: Record): void { const filePath = getConfigPath() const dir = path.dirname(filePath) - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) - const tmp = `${filePath}.tmp.${Date.now()}` - fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8') + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = `${filePath}.tmp.${crypto.randomBytes(8).toString('hex')}` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) fs.renameSync(tmp, filePath) } diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index b08875ab..46b52aa4 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -144,10 +144,16 @@ async function dispatchOutboundImage(chatId: string, pending: PendingUpload): Pr break } case 'url': { - const resp = await fetch(pending.source.url) - if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`) - buffer = Buffer.from(await resp.arrayBuffer()) - mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png' + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 30_000) + try { + const resp = await fetch(pending.source.url, { signal: controller.signal }) + if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`) + buffer = Buffer.from(await resp.arrayBuffer()) + mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png' + } finally { + clearTimeout(timer) + } break } } diff --git a/desktop/README.md b/desktop/README.md index eeb617ba..dfecefeb 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -15,11 +15,11 @@ bun run tauri dev # macOS (Apple Silicon) ./scripts/build-macos-arm64.sh -# Windows (x64) +# Windows (x64, MSI only) .\scripts\build-windows-x64.ps1 ``` -构建产物位于 `build-artifacts/` 目录。 +构建产物位于 `build-artifacts/` 目录,文件名会显式包含平台、架构和包类型。 ## 常见问题 diff --git a/desktop/bun.lock b/desktop/bun.lock index 52d20da9..13c4092d 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "@tailwindcss/typography": "^0.5.19", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "lucide-react": "^0.469.0", "marked": "^15.0.7", @@ -470,6 +472,10 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/desktop/package.json b/desktop/package.json index b43d7818..95e9c75a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "claude-code-desktop", "private": true, - "version": "0.1.5", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -18,6 +18,8 @@ "dependencies": { "@tailwindcss/typography": "^0.5.19", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "lucide-react": "^0.469.0", "marked": "^15.0.7", diff --git a/desktop/public/app-icon.jpg b/desktop/public/app-icon.jpg deleted file mode 100644 index 03098747..00000000 Binary files a/desktop/public/app-icon.jpg and /dev/null differ diff --git a/desktop/public/app-icon.png b/desktop/public/app-icon.png new file mode 100644 index 00000000..02f20c4c Binary files /dev/null and b/desktop/public/app-icon.png differ diff --git a/desktop/public/app-icon.svg b/desktop/public/app-icon.svg new file mode 100644 index 00000000..582d3c54 --- /dev/null +++ b/desktop/public/app-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/desktop/scripts/build-macos-arm64.sh b/desktop/scripts/build-macos-arm64.sh index e5e1998a..407d7710 100755 --- a/desktop/scripts/build-macos-arm64.sh +++ b/desktop/scripts/build-macos-arm64.sh @@ -78,6 +78,11 @@ rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"* rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/bundle" rm -rf "${DESKTOP_DIR}/src-tauri/target/release/bundle" rm -rf "${DESKTOP_DIR}/dist" +rm -f "${DESKTOP_DIR}/tsconfig.tsbuildinfo" +rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/build/claude-code-desktop-"* +rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/.fingerprint/claude-code-desktop-"* +rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/claude_code_desktop-"* +rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/libclaude_code_desktop-"* echo "[build-macos-arm64] Rebuilding frontend (tsc + vite)..." (cd "${DESKTOP_DIR}" && bun run build) @@ -217,19 +222,55 @@ APPLESCRIPT rm -f "${rw_dmg}" } +codesign_cdhash() { + local executable="$1" + codesign -d --verbose=4 "${executable}" 2>&1 \ + | awk -F= '/^CDHash=/{print $2; exit}' +} + +sign_canonical_app_bundle() { + local app_bundle="$1" + local sidecar="${app_bundle}/Contents/MacOS/claude-sidecar" + local sidecar_cdhash_before="" + local sidecar_cdhash_after="" + + if [[ -x "${sidecar}" ]]; then + sidecar_cdhash_before="$(codesign_cdhash "${sidecar}")" + fi + + # Tauri --no-sign leaves the outer .app with no sealed resources, which + # fails strict bundle validation once Resources/icon.icns exists. Sign only + # the outer bundle: do not pass --deep, because re-signing claude-sidecar + # changes its code-signature hash and breaks existing macOS Keychain ACLs. + codesign --force --sign - --timestamp=none "${app_bundle}" + + if [[ -x "${sidecar}" ]]; then + sidecar_cdhash_after="$(codesign_cdhash "${sidecar}")" + if [[ "${sidecar_cdhash_before}" != "${sidecar_cdhash_after}" ]]; then + echo "[build-macos-arm64] ERROR: sidecar signature hash changed while signing app bundle" >&2 + echo "[build-macos-arm64] before=${sidecar_cdhash_before}" >&2 + echo "[build-macos-arm64] after=${sidecar_cdhash_after}" >&2 + exit 1 + fi + fi + + codesign --verify --deep --strict --verbose=2 "${app_bundle}" +} + if [[ -n "${LATEST_DMG}" ]]; then cp -f "${LATEST_DMG}" "${CANONICAL_OUTPUT_DIR}/" fi if [[ -n "${LATEST_APP}" ]]; then - # 注意: 不要再对 .app 重签名。曾经脚本在这里跑过 + # 不要 deep re-sign。曾经脚本在这里跑过 # `codesign --force --deep --sign - --identifier ` 来统一 # sidecar 和外层的 signing identifier,但这会改变 sidecar binary 的 # code signature hash —— macOS Keychain ACL 按 hash 识别 caller, # 重签完再访问时会被 ACL 当作"陌生 binary"静默拒绝,导致 CLI 读不到 # OAuth token,最终请求打到 Anthropic 返回 403 "Request not allowed"。 - # Tauri 的 --no-sign 其实已经做了 ad-hoc 签名,直接用即可。 + # 这里只浅签外层 bundle,让 .app 拥有有效资源封印,同时保留 sidecar hash。 cp -R "${LATEST_APP}" "${CANONICAL_OUTPUT_DIR}/" + sign_canonical_app_bundle "${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}" rm -f "${CANONICAL_OUTPUT_DIR}/"*.dmg build_canonical_dmg \ "${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}" \ diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index 5c01096a..a46293dc 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -162,21 +162,25 @@ async function compileExecutable({ // that causes "load code signature error 4" and SIGKILL at launch. // Fix: strip the broken signature, then ad-hoc sign. if (process.platform === 'darwin') { - console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`) - const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], { - stdout: 'inherit', - stderr: 'inherit', - }) - await strip.exited - - const sign = Bun.spawn( - ['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath], - { stdout: 'inherit', stderr: 'inherit' }, - ) - const signExit = await sign.exited - if (signExit !== 0) { - throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`) - } - console.log(`[build-sidecars] ad-hoc signed ${outputPath}`) + await adHocSignMacBinary(outputPath) } } + +async function adHocSignMacBinary(outputPath: string) { + console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`) + const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], { + stdout: 'inherit', + stderr: 'inherit', + }) + await strip.exited + + const sign = Bun.spawn( + ['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath], + { stdout: 'inherit', stderr: 'inherit' }, + ) + const signExit = await sign.exited + if (signExit !== 0) { + throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`) + } + console.log(`[build-sidecars] ad-hoc signed ${outputPath}`) +} diff --git a/desktop/scripts/build-windows-x64.ps1 b/desktop/scripts/build-windows-x64.ps1 index 7a7d0cb1..ffd72f68 100644 --- a/desktop/scripts/build-windows-x64.ps1 +++ b/desktop/scripts/build-windows-x64.ps1 @@ -15,6 +15,7 @@ $targetTriple = 'x86_64-pc-windows-msvc' $tauriTargetDir = Join-Path $desktopDir 'src-tauri\target' $canonicalOutputDir = Join-Path $desktopDir 'build-artifacts\windows-x64' $activeOutputDir = $canonicalOutputDir +$appVersion = (Get-Content -Path (Join-Path $desktopDir 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json).version function Write-Step { param([string]$Message) @@ -107,6 +108,19 @@ function Get-LatestArtifact { return $null } +function Get-StagedArtifactName { + param([string]$ArtifactName) + + switch -Regex ($ArtifactName) { + '^latest\.json$' { return 'latest.json' } + '\.msi\.zip\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip.sig" } + '\.msi\.zip$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip" } + '\.msi\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.sig" } + '\.msi$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi" } + default { return $ArtifactName } + } +} + function Resolve-OutputDirectory { param([string]$PreferredPath) @@ -181,7 +195,7 @@ $tauriBuildArgs = @( '--target', $targetTriple, '--bundles', - 'nsis,msi', + 'msi', '--ci' ) @@ -228,7 +242,7 @@ $bundleRoots = @( (Join-Path $tauriTargetDir 'release\bundle') ) -$artifactPatterns = @('*.exe', '*.msi', '*.zip', '*.sig', 'latest.json') +$artifactPatterns = @('*.msi', '*.msi.sig', '*.msi.zip', '*.msi.zip.sig', 'latest.json') $copiedArtifacts = New-Object System.Collections.Generic.List[string] foreach ($root in $bundleRoots) { @@ -239,7 +253,8 @@ foreach ($root in $bundleRoots) { foreach ($pattern in $artifactPatterns) { $artifacts = Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue foreach ($artifact in $artifacts) { - $destination = Join-Path $activeOutputDir $artifact.Name + $destinationName = Get-StagedArtifactName -ArtifactName $artifact.Name + $destination = Join-Path $activeOutputDir $destinationName Copy-Item -LiteralPath $artifact.FullName -Destination $destination -Force if (-not $copiedArtifacts.Contains($destination)) { $copiedArtifacts.Add($destination) | Out-Null @@ -248,25 +263,19 @@ foreach ($root in $bundleRoots) { } } -$nsisInstaller = Get-LatestArtifact -SearchRoots @( - (Join-Path $tauriTargetDir "$targetTriple\release\bundle\nsis"), - (Join-Path $tauriTargetDir 'release\bundle\nsis') -) -Patterns @('*.exe') - $msiInstaller = Get-LatestArtifact -SearchRoots @( (Join-Path $tauriTargetDir "$targetTriple\release\bundle\msi"), (Join-Path $tauriTargetDir 'release\bundle\msi') ) -Patterns @('*.msi') -$nsisInstallerPath = if ($nsisInstaller) { $nsisInstaller.FullName } else { 'not found' } $msiInstallerPath = if ($msiInstaller) { $msiInstaller.FullName } else { 'not found' } $buildInfo = @( + "App version: $appVersion" "Target triple: $targetTriple" "Canonical output: $canonicalOutputDir" "Actual output: $activeOutputDir" - "NSIS installer: $nsisInstallerPath" - "MSI installer: $msiInstallerPath" + "Windows installer (MSI): $msiInstallerPath" "Artifacts copied: $($copiedArtifacts.Count)" "Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')" ) @@ -276,12 +285,6 @@ Set-Content -Path (Join-Path $activeOutputDir 'BUILD_INFO.txt') -Value $buildInf Write-Host '' Write-Step 'Build finished.' Write-Step "Artifacts output: $activeOutputDir" -if ($nsisInstaller) { - Write-Step "NSIS installer source: $($nsisInstaller.FullName)" -} else { - Write-Step 'No NSIS installer found under bundle directories.' -} - if ($msiInstaller) { Write-Step "MSI installer source: $($msiInstaller.FullName)" } else { diff --git a/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh b/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh new file mode 100755 index 00000000..9c2b2e53 --- /dev/null +++ b/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required for this UI E2E" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/cc-haha-e2e-deepseek-thinking-$$}" +CONFIG_DIR="${ARTIFACT_DIR}/config" +UPSTREAM_PORT="${UPSTREAM_PORT:-49391}" +API_PORT="${API_PORT:-49392}" +WEB_PORT="${WEB_PORT:-49393}" +SESSION_NAME="${SESSION_NAME:-cc-haha-deepseek-thinking-ui-$$}" + +mkdir -p "${ARTIFACT_DIR}" + +UPSTREAM_PID="" +SERVER_PID="" +WEB_PID="" + +cleanup() { + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ -n "${WEB_PID}" ]]; then kill "${WEB_PID}" >/dev/null 2>&1 || true; fi + if [[ -n "${SERVER_PID}" ]]; then kill "${SERVER_PID}" >/dev/null 2>&1 || true; fi + if [[ -n "${UPSTREAM_PID}" ]]; then kill "${UPSTREAM_PID}" >/dev/null 2>&1 || true; fi + wait "${WEB_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${UPSTREAM_PID}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +ARTIFACT_DIR="${ARTIFACT_DIR}" UPSTREAM_PORT="${UPSTREAM_PORT}" node --input-type=module <<'NODE' & +import http from 'node:http' +import { mkdirSync, writeFileSync } from 'node:fs' + +const artifactDir = process.env.ARTIFACT_DIR +const port = Number(process.env.UPSTREAM_PORT) +mkdirSync(artifactDir, { recursive: true }) +const captures = [] + +function record(req, body) { + captures.push({ + ts: new Date().toISOString(), + method: req.method, + url: req.url, + headers: req.headers, + body, + }) + writeFileSync(`${artifactDir}/captures.json`, JSON.stringify(captures, null, 2)) +} + +function anthropicSse() { + return [ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"msg_ui_test","type":"message","role":"assistant","model":"deepseek-v4-pro","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}', + '', + 'event: content_block_start', + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', + '', + 'event: content_block_stop', + 'data: {"type":"content_block_stop","index":0}', + '', + 'event: message_delta', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}', + '', + 'event: message_stop', + 'data: {"type":"message_stop"}', + '', + ].join('\n') +} + +function openaiChatSse() { + return [ + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}', + '', + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}', + '', + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '', + ].join('\n') +} + +http.createServer((req, res) => { + let raw = '' + req.on('data', chunk => { raw += chunk }) + req.on('end', () => { + let body = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = { raw } + } + record(req, body) + + const hasConflict = + body?.thinking?.type === 'disabled' && + body?.output_config?.effort !== undefined + if (hasConflict) { + res.writeHead(400, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ + error: { + message: 'thinking options type cannot be disabled when reasoning_effort is set', + type: 'invalid_request_error', + param: null, + code: 'invalid_request_error', + }, + })) + return + } + + if (req.url?.includes('/v1/chat/completions')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(openaiChatSse()) + return + } + + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(anthropicSse()) + }) +}).listen(port, '127.0.0.1', () => { + console.log(`fake upstream listening on ${port}`) +}) +NODE +UPSTREAM_PID=$! + +( + cd "${ROOT_DIR}" + CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" \ + bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) & +SERVER_PID=$! + +( + cd "${ROOT_DIR}/desktop" + VITE_DESKTOP_SERVER_URL="http://127.0.0.1:${API_PORT}" \ + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) & +WEB_PID=$! + +wait_for_url() { + local url="$1" + for _ in {1..80}; do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "Timed out waiting for ${url}" >&2 + exit 1 +} + +wait_for_url "http://127.0.0.1:${API_PORT}/health" +wait_for_url "http://127.0.0.1:${WEB_PORT}" + +AB=(agent-browser --session "${SESSION_NAME}") +APP_URL="http://127.0.0.1:${WEB_PORT}" +UPSTREAM_URL="http://127.0.0.1:${UPSTREAM_PORT}" + +"${AB[@]}" open "${APP_URL}" >/dev/null + +ui_eval() { + "${AB[@]}" eval "$1" >/dev/null +} + +ui_eval " +const buttonByName = (name) => [...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name); +buttonByName('设置')?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('添加服务商')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); +}; +const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')] + .find((node) => node.getAttribute('placeholder') === placeholder); +setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}/anthropic'); +setValue(byPlaceholder('sk-...'), 'ui-test-key'); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '添加' && !node.disabled) + ?.click(); +" +"${AB[@]}" wait 700 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('设为默认')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').includes('通用')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '中') + ?.click(); +const thinking = [...document.querySelectorAll('input[type=\"checkbox\"]')] + .find((node) => node.getAttribute('aria-label') === '启用思考模式'); +if (thinking?.checked) thinking.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();" +"${AB[@]}" wait 300 >/dev/null +"${AB[@]}" fill textarea "Say ok" >/dev/null +"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null +"${AB[@]}" wait 5000 >/dev/null + +node --input-type=module < capture.url.includes('/anthropic/v1/messages?beta=true')) +if (!main) throw new Error('No DeepSeek Anthropic UI request captured') +if (main.body?.thinking?.type !== 'disabled') throw new Error('DeepSeek request did not disable thinking') +if (main.body?.output_config?.effort !== undefined) { + throw new Error('DeepSeek request still sends output_config.effort with disabled thinking') +} +NODE + +ui_eval " +const buttonByName = (name) => [...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name); +buttonByName('设置')?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('添加服务商')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === 'Custom') + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('Anthropic Messages')) + ?.click(); +" +"${AB[@]}" wait 100 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('OpenAI Chat Completions')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); +}; +const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')] + .find((node) => node.getAttribute('placeholder') === placeholder); +setValue(byPlaceholder('服务商名称'), 'Custom OpenAI DeepSeek'); +setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}'); +setValue(byPlaceholder('sk-...'), 'ui-test-key'); +setValue(byPlaceholder('Model ID'), 'deepseek-v4-pro'); +const sameAsMain = [...document.querySelectorAll('input')] + .filter((node) => node.getAttribute('placeholder') === '与主模型相同'); +setValue(sameAsMain[0], 'deepseek-v4-flash'); +setValue(sameAsMain[1], 'deepseek-v4-pro'); +setValue(sameAsMain[2], 'deepseek-v4-pro'); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '添加' && !node.disabled) + ?.click(); +" +"${AB[@]}" wait 700 >/dev/null +ui_eval " +const buttons = [...document.querySelectorAll('button')] + .filter((node) => (node.textContent || '').includes('设为默认')); +buttons.at(-1)?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === '新建会话') + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +"${AB[@]}" fill textarea "Say ok through custom openai" >/dev/null +"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null +"${AB[@]}" wait 5000 >/dev/null + +node --input-type=module < capture.url.includes('/v1/chat/completions')) +if (!openai) throw new Error('No Custom OpenAI Chat UI request captured') +if (openai.body?.thinking !== undefined) throw new Error('OpenAI Chat request leaked Anthropic thinking') +if (openai.body?.output_config !== undefined) throw new Error('OpenAI Chat request leaked Anthropic output_config') +if (openai.body?.reasoning_effort !== undefined) throw new Error('OpenAI Chat request unexpectedly sent reasoning_effort') +NODE + +echo "UI E2E passed. Artifacts: ${ARTIFACT_DIR}" diff --git a/desktop/scripts/e2e-install-center-agent-browser.sh b/desktop/scripts/e2e-install-center-agent-browser.sh new file mode 100755 index 00000000..b0a21e5a --- /dev/null +++ b/desktop/scripts/e2e-install-center-agent-browser.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-install-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-install-e2e-${RUN_ID}-XXXX")" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" + +if [[ "${CC_HAHA_E2E_USE_REAL_CONFIG:-1}" == "1" ]]; then + CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +else + CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${ARTIFACT_DIR}/claude-config}" + mkdir -p "${CONFIG_DIR}" +fi + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ $exit_code -ne 0 ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +echo "Starting isolated backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +browser_body() { + agent-browser --session "${SESSION_NAME}" get text body +} + +wait_for_body_contains() { + local needle="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if browser_body | grep -Fq "${needle}"; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for page text: ${needle}" >&2 + return 1 +} + +wait_for_body_contains_any() { + local attempts="$1" + shift + + for _ in $(seq 1 "${attempts}"); do + local body + body="$(browser_body)" + for needle in "$@"; do + if grep -Fq "${needle}" <<<"${body}"; then + return 0 + fi + done + sleep 2 + done + + echo "Timed out waiting for any expected page text: $*" >&2 + return 1 +} + +wait_for_path() { + local path="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if [[ -e "${path}" ]]; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for path: ${path}" >&2 + return 1 +} + +AB="agent-browser --session ${SESSION_NAME}" +TELEGRAM_PROMPT="请根据 https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram 安装 Telegram 官方插件。如果 URL 对应官方 external_plugins,请直接识别 plugin id 并完成用户级安装,然后告诉我结果。" +SKILL_PROMPT="请根据 https://www.aitmpl.com/component/skill/creative-design/ui-ux-pro-max 这个 skill 页面安装 ui-ux-pro-max。如果页面里有官方安装命令,请直接识别并执行,然后告诉我结果。" +PLUGIN_DIR="${CONFIG_DIR}/plugins/cache/claude-plugins-official/telegram" +SKILL_DIR="${CONFIG_DIR}/skills/ui-ux-pro-max" + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +${AB} open "${WEB_URL}" +${AB} click 'button:has-text("Settings")' +${AB} click 'button:has-text("Install")' +${AB} screenshot "${ARTIFACT_DIR}/01-install-center.png" >/dev/null + +run_install_prompt() { + local prompt="$1" + ${AB} click 'button:has-text("New install chat")' + ${AB} fill 'textarea' "${prompt}" + ${AB} click 'button:has-text("Send request")' +} + +echo "Running plugin install flow" +run_install_prompt "${TELEGRAM_PROMPT}" +wait_for_body_contains 'telegram@claude-plugins-official' +wait_for_path "${PLUGIN_DIR}" +${AB} click 'button:has-text("Open Plugins")' +wait_for_body_contains 'Enabled1' +wait_for_body_contains 'telegram Enabled' +${AB} screenshot "${ARTIFACT_DIR}/02-plugin-installed.png" >/dev/null + +echo "Running skill install flow" +${AB} click 'button:has-text("Install")' +run_install_prompt "${SKILL_PROMPT}" +wait_for_body_contains 'claude-code-templates@latest' +wait_for_path "${SKILL_DIR}" +${AB} click 'button:has-text("Open Skills")' +wait_for_body_contains 'Installed Skills' +wait_for_body_contains 'ui-ux-pro-max' +${AB} screenshot "${ARTIFACT_DIR}/03-skill-installed.png" >/dev/null + +echo "Install Center E2E passed" +echo "Config dir: ${CONFIG_DIR}" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" diff --git a/desktop/scripts/e2e-rewind-agent-browser.sh b/desktop/scripts/e2e-rewind-agent-browser.sh new file mode 100755 index 00000000..3b8c6e66 --- /dev/null +++ b/desktop/scripts/e2e-rewind-agent-browser.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-rewind-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-e2e-${RUN_ID}-XXXX")" +PROJECT_DIR="${ARTIFACT_DIR}/project" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" + +mkdir -p "${PROJECT_DIR}/src" +cat > "${PROJECT_DIR}/src/app.js" <<'EOF' +export const ORIGINAL_VALUE = 'before-rewind' + +export function readValue() { + return ORIGINAL_VALUE +} +EOF +cat > "${PROJECT_DIR}/README.md" <<'EOF' +# Rewind E2E Fixture + +This project is created automatically by the agent-browser rewind E2E script. +EOF +cat > "${PROJECT_DIR}/package.json" <<'EOF' +{ + "name": "rewind-e2e-fixture", + "private": true, + "type": "module" +} +EOF + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ $exit_code -ne 0 ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +echo "Starting backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1 +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \ + -H 'Content-Type: application/json' \ + -d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \ + | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')" + +UNIQUE_TITLE="E2E Rewind ${RUN_ID}" +curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \ + -H 'Content-Type: application/json' \ + -d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null + +PROMPT="Edit src/app.js and replace before-rewind with after-rewind. Do not modify any other file. Reply with DONE when finished." +TARGET_FILE="${PROJECT_DIR}/src/app.js" + +AB="agent-browser --session ${SESSION_NAME}" + +${AB} open "${WEB_URL}" +${AB} wait 2000 +${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();" +${AB} wait 2000 +${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null + +${AB} fill '#sidebar-search' "${UNIQUE_TITLE}" +${AB} find text "${UNIQUE_TITLE}" click +${AB} wait 1000 +${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null + +${AB} fill 'textarea' "${PROMPT}" +${AB} find role button click --name "Run" + +for _ in $(seq 1 180); do + ${AB} find role button click --name "Allow" >/dev/null 2>&1 || true + ${AB} find role button click --name "Allow for session" >/dev/null 2>&1 || true + if grep -q "after-rewind" "${TARGET_FILE}"; then + break + fi + sleep 2 +done + +if ! grep -q "after-rewind" "${TARGET_FILE}"; then + echo "Timed out waiting for edited file contents" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-edit-timeout.png" >/dev/null || true + exit 1 +fi + +${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null + +${AB} find role button click --name "Undo current turn changes" +${AB} wait 1500 +${AB} screenshot "${ARTIFACT_DIR}/04-undo-current-turn-confirm.png" >/dev/null +${AB} find role button click --name "Undo current turn" + +for _ in $(seq 1 120); do + if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then + break + fi + sleep 1 +done + +if ! grep -q "before-rewind" "${TARGET_FILE}" || grep -q "after-rewind" "${TARGET_FILE}"; then + echo "Timed out waiting for rewind to restore original file contents" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-rewind-timeout.png" >/dev/null || true + exit 1 +fi + +${AB} wait 1000 +PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')" +if [[ "${PREFILL_VALUE}" != "${PROMPT}" ]]; then + echo "Composer prefill mismatch after rewind" >&2 + echo "Expected: ${PROMPT}" >&2 + echo "Actual: ${PREFILL_VALUE}" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true + exit 1 +fi + +MESSAGE_COUNT="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \ + | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.messages.length);')" +if [[ "${MESSAGE_COUNT}" != "0" ]]; then + echo "Expected rewound session to have 0 transcript messages, got ${MESSAGE_COUNT}" >&2 + exit 1 +fi + +${AB} screenshot "${ARTIFACT_DIR}/05-after-rewind.png" >/dev/null + +echo "Rewind E2E passed" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" diff --git a/desktop/scripts/e2e-rewind-complex-agent-browser.sh b/desktop/scripts/e2e-rewind-complex-agent-browser.sh new file mode 100755 index 00000000..265d8c02 --- /dev/null +++ b/desktop/scripts/e2e-rewind-complex-agent-browser.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-rewind-complex-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-complex-e2e-${RUN_ID}-XXXX")" +PROJECT_DIR="${ARTIFACT_DIR}/project" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" + +mkdir -p "${PROJECT_DIR}/src" +cat > "${PROJECT_DIR}/src/app.js" <<'EOF' +export const STEP = 'base' + +export function readStep() { + return STEP +} +EOF +cat > "${PROJECT_DIR}/README.md" <<'EOF' +# Complex Rewind E2E Fixture + +Initial README content. +EOF +cat > "${PROJECT_DIR}/package.json" <<'EOF' +{ + "name": "complex-rewind-e2e-fixture", + "private": true, + "type": "module" +} +EOF + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ $exit_code -ne 0 ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +echo "Starting backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1 +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +try_agent_browser() { + local pid + (agent-browser --session "${SESSION_NAME}" "$@" >/dev/null 2>&1) & + pid=$! + for _ in $(seq 1 5); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + wait "$pid" >/dev/null 2>&1 || true + return 0 + fi + sleep 1 + done + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true +} + +try_allow_buttons() { + try_agent_browser find role button click --name "Allow" + try_agent_browser find role button click --name "Allow for session" +} + +wait_for_file_contains() { + local file="$1" + local expected="$2" + local timeout="${3:-180}" + for _ in $(seq 1 "$timeout"); do + if [[ -f "$file" ]] && grep -q "$expected" "$file"; then + return 0 + fi + try_allow_buttons + sleep 2 + done + echo "Timed out waiting for ${file} to contain ${expected}" >&2 + return 1 +} + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \ + -H 'Content-Type: application/json' \ + -d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \ + | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')" + +UNIQUE_TITLE="Complex Rewind ${RUN_ID}" +curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \ + -H 'Content-Type: application/json' \ + -d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null + +APP_FILE="${PROJECT_DIR}/src/app.js" +README_FILE="${PROJECT_DIR}/README.md" +GENERATED_FILE="${PROJECT_DIR}/src/generated.js" +FIRST_PROMPT="Edit src/app.js and replace STEP = 'base' with STEP = 'turn-one'. Do not modify any other file. Reply with DONE when finished." +SECOND_PROMPT="Make exactly these three changes: 1. In src/app.js replace STEP = 'turn-one' with STEP = 'turn-two'. 2. Append a new line to README.md that says Second turn touched README. 3. Create src/generated.js with exactly: export const GENERATED = 'second-turn'. Reply with DONE when finished." + +AB="agent-browser --session ${SESSION_NAME}" + +${AB} open "${WEB_URL}" +${AB} wait 2000 +${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();" +${AB} wait 2000 +${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null + +${AB} fill '#sidebar-search' "${UNIQUE_TITLE}" +${AB} find text "${UNIQUE_TITLE}" click +${AB} wait 1000 +${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null + +${AB} fill 'textarea' "${FIRST_PROMPT}" +${AB} find role button click --name "Run" +wait_for_file_contains "${APP_FILE}" "turn-one" 180 +if grep -q "turn-two" "${APP_FILE}" || grep -q "Second turn touched README" "${README_FILE}" || [[ -e "${GENERATED_FILE}" ]]; then + echo "Unexpected second-turn state after first prompt" >&2 + exit 1 +fi +${AB} screenshot "${ARTIFACT_DIR}/03-after-first-turn.png" >/dev/null + +${AB} fill 'textarea' "${SECOND_PROMPT}" +${AB} find role button click --name "Run" +wait_for_file_contains "${APP_FILE}" "turn-two" 180 +wait_for_file_contains "${README_FILE}" "Second turn touched README" 60 +wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60 +${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null + +${AB} find role button click --name "Undo current turn changes" +${AB} wait 1500 +${AB} screenshot "${ARTIFACT_DIR}/05-undo-second-turn-confirm.png" >/dev/null +${AB} find role button click --name "Undo current turn" + +for _ in $(seq 1 120); do + if grep -q "turn-one" "${APP_FILE}" \ + && ! grep -q "turn-two" "${APP_FILE}" \ + && ! grep -q "Second turn touched README" "${README_FILE}" \ + && [[ ! -e "${GENERATED_FILE}" ]]; then + break + fi + sleep 1 +done + +if ! grep -q "turn-one" "${APP_FILE}" || grep -q "turn-two" "${APP_FILE}"; then + echo "src/app.js was not restored to first-turn state" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-app-restore.png" >/dev/null || true + exit 1 +fi +if grep -q "Second turn touched README" "${README_FILE}"; then + echo "README.md still contains second-turn edit" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-readme-restore.png" >/dev/null || true + exit 1 +fi +if [[ -e "${GENERATED_FILE}" ]]; then + echo "Generated file still exists after rewinding second turn" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-generated-file.png" >/dev/null || true + exit 1 +fi + +${AB} wait 1000 +PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')" +if [[ "${PREFILL_VALUE}" != "${SECOND_PROMPT}" ]]; then + echo "Composer prefill mismatch after second-turn rewind" >&2 + echo "Expected: ${SECOND_PROMPT}" >&2 + echo "Actual: ${PREFILL_VALUE}" >&2 + ${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true + exit 1 +fi + +TRANSCRIPT_CHECK="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \ + | FIRST_PROMPT="${FIRST_PROMPT}" SECOND_PROMPT="${SECOND_PROMPT}" bun -e ' +const input = await Bun.stdin.text() +const data = JSON.parse(input) +const userMessages = data.messages.filter((message) => message.type === "user") +const texts = userMessages + .map((message) => { + if (typeof message.content === "string") return message.content + if (Array.isArray(message.content)) { + return message.content + .filter((block) => block && block.type === "text") + .map((block) => block.text || "") + .join("\n") + } + return "" + }) +console.log(JSON.stringify({ + total: data.messages.length, + userCount: userMessages.length, + hasFirstPrompt: texts.includes(process.env.FIRST_PROMPT), + hasSecondPrompt: texts.includes(process.env.SECOND_PROMPT), +})) +')" +TRANSCRIPT_USER_COUNT="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.userCount)')" +TRANSCRIPT_HAS_FIRST="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasFirstPrompt ? "1" : "0")')" +TRANSCRIPT_HAS_SECOND="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasSecondPrompt ? "1" : "0")')" +if [[ "${TRANSCRIPT_USER_COUNT}" != "1" || "${TRANSCRIPT_HAS_FIRST}" != "1" || "${TRANSCRIPT_HAS_SECOND}" != "0" ]]; then + echo "Expected rewound session to keep only the first user prompt, got ${TRANSCRIPT_CHECK}" >&2 + exit 1 +fi + +${AB} screenshot "${ARTIFACT_DIR}/06-after-second-turn-rewind.png" >/dev/null + +echo "Complex rewind E2E passed" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" diff --git a/desktop/scripts/e2e-slash-plugin-agent-browser.sh b/desktop/scripts/e2e-slash-plugin-agent-browser.sh new file mode 100644 index 00000000..cae2832b --- /dev/null +++ b/desktop/scripts/e2e-slash-plugin-agent-browser.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +API_URL="${API_URL:-http://127.0.0.1:3457}" +WEB_URL="${WEB_URL:-http://127.0.0.1:5175/?serverUrl=http://127.0.0.1:3457}" +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-webui-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-webui-e2e-${RUN_ID}-XXXX")" +AB=(agent-browser --session "${SESSION_NAME}") + +cleanup() { + local exit_code=$? + "${AB[@]}" close >/dev/null 2>&1 || true + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 +} +trap cleanup EXIT + +wait_for_text() { + local needle="$1" + if ! "${AB[@]}" wait "text=${needle}" >/dev/null 2>&1; then + echo "Timed out waiting for page text: ${needle}" >&2 + "${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-wait-$(echo "${needle}" | tr ' /' '__').png" >/dev/null 2>&1 || true + return 1 + fi +} + +press_escape() { + "${AB[@]}" press Escape >/dev/null 2>&1 || true +} + +focus_composer() { + "${AB[@]}" click textarea >/dev/null 2>&1 || "${AB[@]}" focus textarea >/dev/null 2>&1 || true +} + +submit_slash_command() { + local command="$1" + focus_composer + "${AB[@]}" fill textarea "${command}" + "${AB[@]}" press Enter +} + +healthcheck() { + curl -fsS "${API_URL}/health" >/dev/null +} + +select_plugin_targets() { + DETAIL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r ' + .plugins + | map(select((.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills) > 0)) + | sort_by(-(.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills)) + | .[0].id // empty + ')" + + ENABLED_SKILL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r ' + .plugins + | map(select(.enabled == true and .componentCounts.skills > 0)) + | sort_by(-.componentCounts.skills) + | .[0].id // empty + ')" + + MCP_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r ' + .plugins + | map(select(.enabled == true and .componentCounts.mcpServers > 0)) + | sort_by(-.componentCounts.mcpServers) + | .[0].id // empty + ')" + + if [[ -z "${DETAIL_PLUGIN_ID}" ]]; then + echo "No plugin with commands/agents/hooks/skills was found in current API data." >&2 + exit 1 + fi + + if [[ -z "${ENABLED_SKILL_PLUGIN_ID}" ]]; then + echo "No enabled plugin with skills was found in current API data." >&2 + exit 1 + fi + + if [[ -z "${MCP_PLUGIN_ID}" ]]; then + echo "No plugin with MCP servers was found in current API data." >&2 + exit 1 + fi + + DETAIL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.name')" + DETAIL_PLUGIN_ENABLED="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.enabled')" + DETAIL_COMMAND="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.commandEntries[0].name // empty')" + DETAIL_AGENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].name // empty')" + DETAIL_AGENT_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].displayName // .detail.agentEntries[0].name // empty')" + DETAIL_HOOK_EVENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].event // empty')" + DETAIL_HOOK_ACTION="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].actions[0] // empty')" + DETAIL_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')" + DETAIL_SKILL_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].displayName // .detail.skillEntries[0].name // empty')" + DETAIL_SKILL_DESCRIPTION="$(curl -fsS --get --data-urlencode "source=plugin" --data-urlencode "name=${DETAIL_SKILL_NAME}" "${API_URL}/api/skills/detail" | jq -r '.detail.meta.description // empty')" + DETAIL_SKILL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.name')" + + MCP_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.name')" + MCP_SERVER_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].name // empty')" + MCP_SERVER_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].displayName // .detail.mcpServerEntries[0].name // empty')" + MCP_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')" +} + +open_plugin_detail() { + local plugin_name="$1" + local escaped_name="${plugin_name//\"/\\\"}" + if "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_name}\")); if(!target) throw new Error('plugin button not found'); target.click();" >/dev/null 2>&1; then + wait_for_text "Bundled capabilities" + return 0 + fi + + echo "Failed to open plugin detail for: ${plugin_name}" >&2 + "${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-open-plugin-${plugin_name}.png" >/dev/null 2>&1 || true + return 1 +} + +go_back_to_plugin_list() { + "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('Back to list')); if(!target) throw new Error('back button not found'); target.click();" >/dev/null + wait_for_text "Browse installed plugins" +} + +open_settings_sidebar_tab() { + local tab_name="$1" + "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.trim()==='${tab_name}'); if(!target) throw new Error('settings tab not found: ${tab_name}'); target.click();" >/dev/null +} + +click_visible_card_text() { + local text="$1" + local escaped_text="${text//\"/\\\"}" + "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_text}\")); if(!target) throw new Error('button not found: ${escaped_text}'); target.click();" >/dev/null +} + +healthcheck +select_plugin_targets + +echo "Using detail plugin: ${DETAIL_PLUGIN_NAME} (${DETAIL_PLUGIN_ID})" +echo "Using enabled skill plugin: ${DETAIL_SKILL_PLUGIN_NAME} (${ENABLED_SKILL_PLUGIN_ID})" +echo "Using MCP plugin: ${MCP_PLUGIN_NAME} (${MCP_PLUGIN_ID})" + +"${AB[@]}" open "${WEB_URL}" +"${AB[@]}" wait --load networkidle +wait_for_text "Claude Code Haha" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null + +# Always work from a fresh chat surface so slash-command behavior is deterministic. +"${AB[@]}" find role button click --name "New session" +"${AB[@]}" wait textarea >/dev/null + +submit_slash_command "/mcp" +wait_for_text "Available MCP tools" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-mcp-panel.png" >/dev/null +press_escape +"${AB[@]}" wait 300 >/dev/null + +submit_slash_command "/skills" +wait_for_text "Available skills" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-skills-panel.png" >/dev/null +press_escape +"${AB[@]}" wait 300 >/dev/null + +submit_slash_command "/plugin" +wait_for_text "Browse installed plugins" +wait_for_text "Plugin Manager" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-plugins-list.png" >/dev/null + +open_plugin_detail "${DETAIL_PLUGIN_NAME}" +wait_for_text "Commands" +wait_for_text "Agents" +wait_for_text "Hooks" +wait_for_text "Skills" +if [[ -n "${DETAIL_COMMAND}" ]]; then + wait_for_text "/${DETAIL_COMMAND}" +fi +if [[ -n "${DETAIL_AGENT}" ]]; then + wait_for_text "${DETAIL_AGENT}" +fi +if [[ -n "${DETAIL_HOOK_EVENT}" ]]; then + wait_for_text "${DETAIL_HOOK_EVENT}" +fi +if [[ -n "${DETAIL_HOOK_ACTION}" ]]; then + wait_for_text "${DETAIL_HOOK_ACTION}" +fi +if [[ -n "${DETAIL_SKILL_NAME}" ]]; then + wait_for_text "/${DETAIL_SKILL_NAME}" +fi +"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-plugin-detail-main.png" >/dev/null + +open_settings_sidebar_tab "Plugins" +wait_for_text "Plugin Detail" +go_back_to_plugin_list +open_plugin_detail "${DETAIL_SKILL_PLUGIN_NAME}" + +if [[ -n "${DETAIL_SKILL_LABEL}" ]]; then + click_visible_card_text "${DETAIL_SKILL_LABEL}" + wait_for_text "Skill metadata" + wait_for_text "${DETAIL_SKILL_LABEL}" + if [[ -n "${DETAIL_SKILL_DESCRIPTION}" ]]; then + wait_for_text "${DETAIL_SKILL_DESCRIPTION}" + fi + "${AB[@]}" screenshot "${ARTIFACT_DIR}/06-skill-detail-from-plugin.png" >/dev/null + + open_settings_sidebar_tab "Skills" + wait_for_text "Skill Browser" + wait_for_text "Plugin" + wait_for_text "${DETAIL_SKILL_LABEL}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/07-skills-list-with-plugin-group.png" >/dev/null +fi + +open_settings_sidebar_tab "Plugins" +wait_for_text "Plugin Detail" + +if [[ "${DETAIL_PLUGIN_ENABLED}" == "true" && -n "${DETAIL_AGENT_LABEL}" ]]; then + click_visible_card_text "${DETAIL_AGENT_LABEL}" + wait_for_text "Agent Profile" + wait_for_text "${DETAIL_AGENT}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/08-agent-detail-from-plugin.png" >/dev/null + + open_settings_sidebar_tab "Agents" + wait_for_text "Agent Browser" + wait_for_text "Plugin" + wait_for_text "${DETAIL_AGENT}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/09-agents-list-with-plugin-group.png" >/dev/null +fi + +go_back_to_plugin_list +open_plugin_detail "${MCP_PLUGIN_NAME}" +wait_for_text "MCP servers" +if [[ -n "${MCP_SERVER_NAME}" ]]; then + wait_for_text "${MCP_SERVER_LABEL:-$MCP_SERVER_NAME}" +fi +if [[ -n "${MCP_SKILL_NAME}" ]]; then + wait_for_text "/${MCP_SKILL_NAME}" +fi +"${AB[@]}" screenshot "${ARTIFACT_DIR}/10-plugin-detail-mcp.png" >/dev/null + +if [[ -n "${MCP_SERVER_LABEL}" ]]; then + click_visible_card_text "${MCP_SERVER_LABEL}" + wait_for_text "${MCP_SERVER_NAME}" + wait_for_text "Plugin" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/11-mcp-detail-from-plugin.png" >/dev/null + + open_settings_sidebar_tab "MCP" + wait_for_text "MCP servers" + wait_for_text "Plugin" + wait_for_text "${MCP_SERVER_NAME}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/12-mcp-list-with-plugin-group.png" >/dev/null +fi + +echo "agent-browser web UI regression passed" +echo "Artifacts: ${ARTIFACT_DIR}" diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index db070432..cd0937f0 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -17,18 +17,21 @@ * launcher-only 参数。 */ +import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting' + const rawArgs = process.argv.slice(2) -if (rawArgs.length === 0) { +const invocation = resolveSidecarInvocation(rawArgs) +if (!invocation.mode) { console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")') process.exit(2) } -const mode = rawArgs[0]! -const restArgs = rawArgs.slice(1) +const mode = invocation.mode +const restArgs = invocation.restArgs if (mode === 'adapters') { await runAdapters(restArgs) } else { - const { appRoot, args } = parseLauncherArgs(restArgs) + const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot) process.env.CLAUDE_APP_ROOT = appRoot process.env.CALLER_DIR ||= process.cwd() @@ -133,24 +136,3 @@ async function runAdapters(rawArgs: string[]): Promise { // / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外 // setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。 } - -function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } { - const nextArgs: string[] = [] - let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null - - for (let index = 0; index < rawArgs.length; index++) { - const arg = rawArgs[index] - if (arg === '--app-root') { - appRoot = rawArgs[index + 1] ?? null - index += 1 - continue - } - nextArgs.push(arg!) - } - - if (!appRoot) { - throw new Error('Missing --app-root for claude-sidecar') - } - - return { appRoot, args: nextArgs } -} diff --git a/desktop/sidecars/launcherRouting.test.ts b/desktop/sidecars/launcherRouting.test.ts new file mode 100644 index 00000000..84d26637 --- /dev/null +++ b/desktop/sidecars/launcherRouting.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting' + +describe('resolveSidecarInvocation', () => { + it('keeps explicit sidecar modes unchanged', () => { + expect( + resolveSidecarInvocation( + ['server', '--host', '127.0.0.1'], + '/tmp/claude-sidecar', + ), + ).toEqual({ + mode: 'server', + restArgs: ['--host', '127.0.0.1'], + defaultAppRoot: null, + }) + }) + + it('defaults claude-haha invocations to cli mode', () => { + expect( + resolveSidecarInvocation( + ['plugin', 'install', 'demo'], + '/Users/demo/.local/bin/claude-haha', + ), + ).toEqual({ + mode: 'cli', + restArgs: ['plugin', 'install', 'demo'], + defaultAppRoot: '/Users/demo/.local/bin', + }) + }) +}) + +describe('parseLauncherArgs', () => { + it('falls back to the provided default app root', () => { + expect( + parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'), + ).toEqual({ + appRoot: '/Users/demo/.local/bin', + args: ['plugin', 'install', 'demo'], + }) + }) + + it('lets explicit app root override the default', () => { + expect( + parseLauncherArgs( + ['--app-root', '/tmp/app', 'plugin', 'install', 'demo'], + '/Users/demo/.local/bin', + ), + ).toEqual({ + appRoot: '/tmp/app', + args: ['plugin', 'install', 'demo'], + }) + }) +}) diff --git a/desktop/sidecars/launcherRouting.ts b/desktop/sidecars/launcherRouting.ts new file mode 100644 index 00000000..2af59c6a --- /dev/null +++ b/desktop/sidecars/launcherRouting.ts @@ -0,0 +1,64 @@ +import path from 'node:path' + +export type SidecarMode = 'server' | 'cli' | 'adapters' + +const EXPLICIT_MODES = new Set(['server', 'cli', 'adapters']) +const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe']) + +export function resolveSidecarInvocation( + rawArgs: string[], + execPath: string = process.execPath, + envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null, +): { + mode: SidecarMode | null + restArgs: string[] + defaultAppRoot: string | null +} { + const explicitMode = rawArgs[0] + if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) { + return { + mode: explicitMode as SidecarMode, + restArgs: rawArgs.slice(1), + defaultAppRoot: envAppRoot, + } + } + + const execName = path.basename(execPath).toLowerCase() + if (DESKTOP_CLI_NAMES.has(execName)) { + return { + mode: 'cli', + restArgs: rawArgs, + defaultAppRoot: envAppRoot ?? path.dirname(execPath), + } + } + + return { + mode: null, + restArgs: rawArgs, + defaultAppRoot: envAppRoot, + } +} + +export function parseLauncherArgs( + rawArgs: string[], + defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null, +): { appRoot: string; args: string[] } { + const nextArgs: string[] = [] + let appRoot: string | null = defaultAppRoot + + for (let index = 0; index < rawArgs.length; index++) { + const arg = rawArgs[index] + if (arg === '--app-root') { + appRoot = rawArgs[index + 1] ?? null + index += 1 + continue + } + nextArgs.push(arg!) + } + + if (!appRoot) { + throw new Error('Missing --app-root for claude-sidecar') + } + + return { appRoot, args: nextArgs } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 98b02845..365ad6f4 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -268,9 +268,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -309,6 +309,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.44" @@ -323,8 +329,10 @@ dependencies = [ [[package]] name = "claude-code-desktop" -version = "0.1.5" +version = "0.2.0" dependencies = [ + "anyhow", + "portable-pty", "serde", "serde_json", "tauri", @@ -333,6 +341,8 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-updater", + "tauri-runtime", + "tauri-runtime-wry", ] [[package]] @@ -486,14 +496,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.23.0" @@ -675,6 +691,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -699,6 +721,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -713,16 +750,16 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -792,6 +829,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1566,9 +1614,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1686,6 +1734,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1716,9 +1794,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -1771,6 +1849,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1803,9 +1887,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2009,6 +2093,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2509,9 +2605,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -2533,6 +2629,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2643,9 +2760,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] @@ -2838,9 +2955,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -2943,9 +3060,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -2969,22 +3086,22 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -3005,9 +3122,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -3291,6 +3408,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial2" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3354,6 +3482,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3397,6 +3541,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" @@ -3629,7 +3789,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3695,7 +3855,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3753,9 +3913,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" dependencies = [ "base64 0.22.1", "brotli", @@ -3780,9 +3940,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3794,9 +3954,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" dependencies = [ "anyhow", "glob", @@ -3805,7 +3965,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -3925,7 +4084,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3948,7 +4107,7 @@ checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3968,14 +4127,15 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", "html5ever 0.29.1", @@ -3986,6 +4146,7 @@ dependencies = [ "log", "memchr", "phf 0.11.3", + "plist", "proc-macro2", "quote", "regex", @@ -3997,7 +4158,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4006,13 +4167,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -4194,6 +4355,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -4254,7 +4430,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -4263,7 +4439,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -4573,9 +4749,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -4586,9 +4762,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -4596,9 +4772,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4606,9 +4782,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -4619,9 +4795,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -4675,9 +4851,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -5235,13 +5411,22 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5370,7 +5555,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 03f749e0..85fcb995 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-desktop" -version = "0.1.5" +version = "0.2.0" edition = "2021" [lib] @@ -8,13 +8,17 @@ name = "claude_code_desktop_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { version = "2", features = [] } +tauri-build = { version = "=2.5.6", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "=2.10.3", features = ["tray-icon"] } +tauri-runtime = "=2.10.1" +tauri-runtime-wry = "=2.10.1" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-process = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +anyhow = "1.0.102" +portable-pty = "0.9.0" diff --git a/desktop/src-tauri/app-icon-macos.svg b/desktop/src-tauri/app-icon-macos.svg new file mode 100644 index 00000000..1fd4682d --- /dev/null +++ b/desktop/src-tauri/app-icon-macos.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/app-icon.svg b/desktop/src-tauri/app-icon.svg index 28d94fb6..f4f73e67 100644 --- a/desktop/src-tauri/app-icon.svg +++ b/desktop/src-tauri/app-icon.svg @@ -1,13 +1,16 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index c5f63adf..0c3f4c33 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index a8a5c47b..3ed6ca7f 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index 31920537..eb20b1ba 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index 40b0b858..b687e89d 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 75743661..981c6993 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index e1cdbdea..2ef24d23 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index e9236bac..6e00e9d8 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index e82297e3..ff785d17 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 8c78c988..26e58bbb 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index 281538d2..57cf5a6e 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 510dbf79..1e7efbb9 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 90ac2049..2b6ea0c7 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index c266dc85..432fe0d6 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index cd0c0266..6eef0c0d 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 533478c5..f4693643 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index a6b36bac..dce478fb 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 9332b6a1..d89378a4 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index fb28c4fc..0a1917d5 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 092b10a8..69b16963 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 3b7fa755..6b76d53d 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index afb60a52..090793b7 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 9fe160e6..49f55cb2 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 9ecb360d..01ffd5c8 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 8f4a2816..e9769d5a 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 2dbd7801..3f3aeb26 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 1cc8ed6d..9577fc19 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index e4fd63d6..0297b63e 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index e19625b1..ed9c9114 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 13d5f894..e8d4a138 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 91719bd5..cd21aa2e 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index 438a007a..b3e4fddb 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index eb7ec88f..5cd4c0ea 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png index ea97dfb1..84b19f85 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 04ff6a6e..f9f8bb3a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png index 04ff6a6e..f9f8bb3a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png index 98bc7501..0c71f6fd 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png index d222072e..cd4e0f45 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 2b1b3a85..dd67f86a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png index 2b1b3a85..dd67f86a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png index fa40e74c..d2ff8a80 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png index 04ff6a6e..f9f8bb3a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 880a13d8..86a22651 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png index 880a13d8..86a22651 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png index 271a343e..67e98dfc 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png index 62bbd5f6..bd440a4f 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png index 271a343e..67e98dfc 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png index 4fffc80d..1a03a61e 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png index 866cd10a..a73c4417 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png index bcfc5631..28c440fc 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 3780ce30..a5bc775b 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7671fce4..5b0312ce 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,21 +1,39 @@ use std::{ - io::{Error as IoError, ErrorKind}, + collections::{HashMap, VecDeque}, + fs, + io::{Error as IoError, ErrorKind, Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, path::PathBuf, - sync::Mutex, + process::{Command as StdCommand, Stdio}, + str, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, Mutex, + }, + thread, time::{Duration, Instant}, }; -#[cfg(target_os = "macos")] +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; -#[cfg(target_os = "macos")] +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::Emitter; -use tauri::{AppHandle, Manager, RunEvent, State}; +use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, RunEvent, State, WindowEvent}; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, ShellExt, }; +const SERVER_STARTUP_LOG_LIMIT: usize = 80; +const MAIN_WINDOW_LABEL: &str = "main"; +const TRAY_SHOW_ID: &str = "tray_show"; +const TRAY_QUIT_ID: &str = "tray_quit"; +const WINDOW_STATE_FILE: &str = "window-state.json"; +const MIN_WINDOW_WIDTH: u32 = 960; +const MIN_WINDOW_HEIGHT: u32 = 640; +const MIN_VISIBLE_PIXELS: i64 = 64; + #[derive(Default)] struct ServerState(Mutex); @@ -30,6 +48,20 @@ struct ServerStatus { startup_error: Option, } +#[derive(Default)] +struct AppExitState { + is_quitting: Mutex, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +struct StoredWindowState { + x: i32, + y: i32, + width: u32, + height: u32, + maximized: bool, +} + /// 与 ServerState 平级的 adapter 子进程状态。 /// /// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期 @@ -39,6 +71,38 @@ struct ServerStatus { #[derive(Default)] struct AdapterState(Mutex>); +#[derive(Default)] +struct TerminalState { + next_id: AtomicU32, + sessions: Mutex>, +} + +struct TerminalSession { + master: Box, + writer: Mutex>, + killer: Mutex>, +} + +#[derive(Serialize, Clone)] +struct TerminalSpawnResult { + session_id: u32, + shell: String, + cwd: String, +} + +#[derive(Serialize, Clone)] +struct TerminalOutputPayload { + session_id: u32, + data: String, +} + +#[derive(Serialize, Clone)] +struct TerminalExitPayload { + session_id: u32, + code: u32, + signal: Option, +} + #[tauri::command] fn get_server_url(state: State<'_, ServerState>) -> Result { let guard = state @@ -72,6 +136,654 @@ fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn prepare_for_update_install(app: AppHandle) -> Result<(), String> { + mark_app_quitting(&app); + stop_server_sidecar(&app); + stop_adapters_sidecar(&app); + + #[cfg(target_os = "windows")] + { + kill_windows_sidecars(); + } + + // Give Windows a short moment to release executable file handles before the + // updater starts replacing bundled sidecars in the install directory. + std::thread::sleep(Duration::from_millis(750)); + Ok(()) +} + +fn mark_app_quitting(app: &AppHandle) { + if let Some(state) = app.try_state::() { + if let Ok(mut is_quitting) = state.is_quitting.lock() { + *is_quitting = true; + } + } +} + +fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool { + if label != MAIN_WINDOW_LABEL { + return false; + } + + app.try_state::() + .and_then(|state| state.is_quitting.lock().ok().map(|value| !*value)) + .unwrap_or(true) +} + +fn is_persistable_window_state(state: &StoredWindowState) -> bool { + state.width >= MIN_WINDOW_WIDTH && state.height >= MIN_WINDOW_HEIGHT +} + +fn has_meaningful_intersection( + state: &StoredWindowState, + monitor_x: i32, + monitor_y: i32, + monitor_width: u32, + monitor_height: u32, +) -> bool { + let left = state.x as i64; + let top = state.y as i64; + let right = left + state.width as i64; + let bottom = top + state.height as i64; + + let monitor_left = monitor_x as i64; + let monitor_top = monitor_y as i64; + let monitor_right = monitor_left + monitor_width as i64; + let monitor_bottom = monitor_top + monitor_height as i64; + + right > monitor_left + MIN_VISIBLE_PIXELS + && bottom > monitor_top + MIN_VISIBLE_PIXELS + && left < monitor_right - MIN_VISIBLE_PIXELS + && top < monitor_bottom - MIN_VISIBLE_PIXELS +} + +fn is_window_state_visible_on_any_monitor( + state: &StoredWindowState, + monitors: &[tauri::Monitor], +) -> bool { + if monitors.is_empty() { + return true; + } + + monitors.iter().any(|monitor| { + let position = monitor.position(); + let size = monitor.size(); + has_meaningful_intersection(state, position.x, position.y, size.width, size.height) + }) +} + +fn window_state_path(app: &AppHandle) -> Option { + match app.path().app_config_dir() { + Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)), + Err(err) => { + eprintln!("[desktop] failed to resolve app config dir: {err}"); + None + } + } +} + +fn read_stored_window_state(app: &AppHandle) -> Option { + let path = window_state_path(app)?; + let data = match fs::read_to_string(&path) { + Ok(data) => data, + Err(err) if err.kind() == ErrorKind::NotFound => return None, + Err(err) => { + eprintln!( + "[desktop] failed to read window state {}: {err}", + path.display() + ); + return None; + } + }; + + match serde_json::from_str::(&data) { + Ok(state) if is_persistable_window_state(&state) => Some(state), + Ok(_) => None, + Err(err) => { + eprintln!( + "[desktop] failed to parse window state {}: {err}", + path.display() + ); + None + } + } +} + +fn write_stored_window_state(app: &AppHandle, state: &StoredWindowState) { + if !is_persistable_window_state(state) { + return; + } + + let Some(path) = window_state_path(app) else { + return; + }; + + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + eprintln!( + "[desktop] failed to create window state directory {}: {err}", + parent.display() + ); + return; + } + } + + let data = match serde_json::to_string_pretty(state) { + Ok(data) => data, + Err(err) => { + eprintln!("[desktop] failed to serialize window state: {err}"); + return; + } + }; + + if let Err(err) = fs::write(&path, data) { + eprintln!( + "[desktop] failed to write window state {}: {err}", + path.display() + ); + } +} + +fn capture_window_state(window: &tauri::WebviewWindow) -> Option { + if window.is_minimized().unwrap_or(false) { + return None; + } + + let position = match window.outer_position() { + Ok(position) => position, + Err(err) => { + eprintln!("[desktop] failed to read window position: {err}"); + return None; + } + }; + let size = match window.outer_size() { + Ok(size) => size, + Err(err) => { + eprintln!("[desktop] failed to read window size: {err}"); + return None; + } + }; + + let state = StoredWindowState { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + maximized: window.is_maximized().unwrap_or(false), + }; + + is_persistable_window_state(&state).then_some(state) +} + +fn save_main_window_state(app: &AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + return; + }; + let Some(state) = capture_window_state(&window) else { + return; + }; + + write_stored_window_state(app, &state); +} + +fn restore_main_window_state(app: &AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + return; + }; + let Some(state) = read_stored_window_state(app) else { + return; + }; + + let monitors = window.available_monitors().unwrap_or_default(); + if !is_window_state_visible_on_any_monitor(&state, &monitors) { + return; + } + + let _ = window.unmaximize(); + let _ = window.set_size(PhysicalSize::new(state.width, state.height)); + let _ = window.set_position(PhysicalPosition::new(state.x, state.y)); + if state.maximized { + let _ = window.maximize(); + } +} + +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +fn setup_system_tray(app: &mut tauri::App) -> tauri::Result<()> { + let menu = MenuBuilder::new(app) + .text(TRAY_SHOW_ID, "Show Claude Code Haha") + .separator() + .text(TRAY_QUIT_ID, "Quit Claude Code Haha") + .build()?; + + let mut tray = TrayIconBuilder::with_id("main-tray") + .tooltip("Claude Code Haha") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id().as_ref() { + TRAY_SHOW_ID => show_main_window(app), + TRAY_QUIT_ID => { + mark_app_quitting(app); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }); + + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + + tray.build(app)?; + + Ok(()) +} + +#[tauri::command] +fn terminal_spawn( + app: AppHandle, + state: State<'_, TerminalState>, + cols: u16, + rows: u16, + cwd: Option, +) -> Result { + let cwd_path = resolve_terminal_cwd(cwd)?; + let shell = default_shell(); + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: rows.max(8), + cols: cols.max(20), + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("open terminal pty: {err}"))?; + + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd_path.as_os_str()); + for (key, value) in terminal_environment(&shell) { + cmd.env(key, value); + } + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|err| format!("spawn terminal shell: {err}"))?; + drop(pair.slave); + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|err| format!("clone terminal reader: {err}"))?; + let writer = pair + .master + .take_writer() + .map_err(|err| format!("open terminal writer: {err}"))?; + let killer = child.clone_killer(); + let session_id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1; + + { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + sessions.insert( + session_id, + TerminalSession { + master: pair.master, + writer: Mutex::new(writer), + killer: Mutex::new(killer), + }, + ); + } + + let output_app = app.clone(); + thread::spawn(move || { + let mut buffer = [0_u8; 8192]; + let mut pending_utf8 = Vec::new(); + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + let data = decode_terminal_output(&mut pending_utf8, &buffer[..n]); + if !data.is_empty() { + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { session_id, data }, + ); + } + } + Err(err) => { + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { + session_id, + data: format!("\r\n[terminal read error: {err}]\r\n"), + }, + ); + break; + } + } + } + if !pending_utf8.is_empty() { + let data = String::from_utf8_lossy(&pending_utf8).to_string(); + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { session_id, data }, + ); + } + }); + + let exit_app = app.clone(); + thread::spawn(move || { + let status = child.wait(); + if let Some(state) = exit_app.try_state::() { + if let Ok(mut sessions) = state.sessions.lock() { + sessions.remove(&session_id); + } + } + match status { + Ok(status) => { + let _ = exit_app.emit( + "terminal-exit", + TerminalExitPayload { + session_id, + code: status.exit_code(), + signal: status.signal().map(ToString::to_string), + }, + ); + } + Err(err) => { + let _ = exit_app.emit( + "terminal-output", + TerminalOutputPayload { + session_id, + data: format!("\r\n[terminal wait error: {err}]\r\n"), + }, + ); + } + } + }); + + Ok(TerminalSpawnResult { + session_id, + shell, + cwd: cwd_path.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +fn terminal_write( + state: State<'_, TerminalState>, + session_id: u32, + data: String, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| "terminal session is not running".to_string())?; + let mut writer = session + .writer + .lock() + .map_err(|_| "terminal writer is unavailable".to_string())?; + writer + .write_all(data.as_bytes()) + .map_err(|err| format!("write terminal input: {err}"))?; + writer + .flush() + .map_err(|err| format!("flush terminal input: {err}"))?; + Ok(()) +} + +#[tauri::command] +fn terminal_resize( + state: State<'_, TerminalState>, + session_id: u32, + cols: u16, + rows: u16, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| "terminal session is not running".to_string())?; + session + .master + .resize(PtySize { + rows: rows.max(8), + cols: cols.max(20), + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("resize terminal: {err}"))?; + Ok(()) +} + +#[tauri::command] +fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), String> { + let session = { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + sessions.remove(&session_id) + }; + + if let Some(session) = session { + let mut killer = session + .killer + .lock() + .map_err(|_| "terminal killer is unavailable".to_string())?; + killer + .kill() + .map_err(|err| format!("kill terminal shell: {err}"))?; + } + Ok(()) +} + +fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { + pending.extend_from_slice(chunk); + let mut output = String::new(); + + loop { + match str::from_utf8(pending) { + Ok(text) => { + output.push_str(text); + pending.clear(); + break; + } + Err(err) => { + let valid_up_to = err.valid_up_to(); + if valid_up_to > 0 { + let text = str::from_utf8(&pending[..valid_up_to]) + .expect("valid_up_to marks a valid UTF-8 prefix"); + output.push_str(text); + pending.drain(..valid_up_to); + continue; + } + + match err.error_len() { + Some(error_len) => { + output.push('\u{fffd}'); + pending.drain(..error_len); + } + None => break, + } + } + } + } + + output +} + +fn terminal_environment(shell: &str) -> HashMap { + let mut env: HashMap = std::env::vars().collect(); + env.extend(login_shell_environment(shell)); + ensure_utf8_locale(&mut env); + env +} + +fn ensure_utf8_locale(env: &mut HashMap) { + let fallback = default_utf8_locale(); + for key in ["LANG", "LC_CTYPE", "LC_ALL"] { + let needs_fallback = env + .get(key) + .map(|value| !is_utf8_locale(value)) + .unwrap_or(true); + if needs_fallback { + env.insert(key.to_string(), fallback.to_string()); + } + } +} + +fn is_utf8_locale(value: &str) -> bool { + let normalized = value.trim().to_ascii_lowercase().replace('-', ""); + normalized.contains("utf8") +} + +fn default_utf8_locale() -> &'static str { + #[cfg(target_os = "macos")] + { + "en_US.UTF-8" + } + #[cfg(all(unix, not(target_os = "macos")))] + { + "C.UTF-8" + } + #[cfg(not(unix))] + { + "C.UTF-8" + } +} + +#[cfg(not(target_os = "windows"))] +fn login_shell_environment(shell: &str) -> HashMap { + let Ok(mut child) = StdCommand::new(shell) + .args(["-l", "-c", "env -0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return HashMap::new(); + }; + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return HashMap::new(); + } + let mut stdout = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); + } + return parse_env_block(&stdout); + } + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return HashMap::new(); + } + Err(_) => return HashMap::new(), + } + } +} + +#[cfg(target_os = "windows")] +fn login_shell_environment(_shell: &str) -> HashMap { + HashMap::new() +} + +fn parse_env_block(bytes: &[u8]) -> HashMap { + bytes + .split(|byte| *byte == 0) + .filter_map(|entry| { + if entry.is_empty() { + return None; + } + let equals = entry.iter().position(|byte| *byte == b'=')?; + if equals == 0 { + return None; + } + let key = String::from_utf8_lossy(&entry[..equals]).to_string(); + let value = String::from_utf8_lossy(&entry[equals + 1..]).to_string(); + Some((key, value)) + }) + .collect() +} + +fn resolve_terminal_cwd(cwd: Option) -> Result { + let path = match cwd.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } + }) { + Some(path) => path, + None => home_dir().unwrap_or( + std::env::current_dir().map_err(|err| format!("resolve current directory: {err}"))?, + ), + }; + + if path.is_dir() { + Ok(path) + } else { + Err(format!("terminal cwd does not exist: {}", path.display())) + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn default_shell() -> String { + #[cfg(target_os = "windows")] + { + std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string()) + } + #[cfg(not(target_os = "windows"))] + { + std::env::var("SHELL").unwrap_or_else(|_| { + if PathBuf::from("/bin/zsh").exists() { + "/bin/zsh".to_string() + } else { + "/bin/bash".to_string() + } + }) + } +} + fn reserve_local_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?; @@ -101,6 +813,32 @@ fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> { )) } +fn push_server_startup_log(logs: &Arc>>, line: String) { + let line = line.trim_end().to_string(); + if line.is_empty() { + return; + } + + let Ok(mut guard) = logs.lock() else { + return; + }; + if guard.len() >= SERVER_STARTUP_LOG_LIMIT { + guard.pop_front(); + } + guard.push_back(line); +} + +fn format_server_startup_error(message: &str, logs: &Arc>>) -> String { + let log_text = logs + .lock() + .ok() + .map(|guard| guard.iter().cloned().collect::>().join("\n")) + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| "No server stdout/stderr was captured before the timeout.".to_string()); + + format!("{message}\n\nRecent server logs:\n{log_text}") +} + fn resolve_app_root(_app: &AppHandle) -> Result { // 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上 // 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ + @@ -114,8 +852,7 @@ fn resolve_app_root(_app: &AppHandle) -> Result { // 我们直接用当前可执行文件所在目录作为 app_root: // Dev: desktop/src-tauri/target// (rust 跑出来的 binary 那一层) // Prod: .app/Contents/MacOS/ (sidecar 二进制的同级目录) - let exe = std::env::current_exe() - .map_err(|err| format!("resolve current exe path: {err}"))?; + let exe = std::env::current_exe().map_err(|err| format!("resolve current exe path: {err}"))?; let dir = exe .parent() .ok_or_else(|| "current exe has no parent dir".to_string())? @@ -131,19 +868,25 @@ fn start_server_sidecar(app: &AppHandle) -> Result { let app_root_arg = app_root.to_string_lossy().to_string(); // 单一合并 sidecar:第一个参数选 server / cli / adapters 模式。 - let sidecar = app + let mut sidecar = app .shell() .sidecar("claude-sidecar") - .map_err(|err| format!("resolve sidecar: {err}"))? - .args([ - "server", - "--app-root", - &app_root_arg, - "--host", - host, - "--port", - &port.to_string(), - ]); + .map_err(|err| format!("resolve sidecar: {err}"))?; + for (key, value) in terminal_environment(&default_shell()) { + sidecar = sidecar.env(key, value); + } + let sidecar = sidecar.args([ + "server", + "--app-root", + &app_root_arg, + "--host", + host, + "--port", + &port.to_string(), + ]); + + let startup_logs = Arc::new(Mutex::new(VecDeque::new())); + let logs_for_task = Arc::clone(&startup_logs); let (mut rx, child) = sidecar .spawn() @@ -154,18 +897,33 @@ fn start_server_sidecar(app: &AppHandle) -> Result { match event { CommandEvent::Stdout(line) => { let line = String::from_utf8_lossy(&line); - println!("[claude-server] {}", line.trim_end()); + let line = line.trim_end(); + println!("[claude-server] {line}"); + push_server_startup_log(&logs_for_task, format!("[stdout] {line}")); } CommandEvent::Stderr(line) => { let line = String::from_utf8_lossy(&line); - eprintln!("[claude-server] {}", line.trim_end()); + let line = line.trim_end(); + eprintln!("[claude-server] {line}"); + push_server_startup_log(&logs_for_task, format!("[stderr] {line}")); + } + CommandEvent::Terminated(payload) => { + let line = format!( + "sidecar exited (code={:?}, signal={:?})", + payload.code, payload.signal + ); + eprintln!("[claude-server] {line}"); + push_server_startup_log(&logs_for_task, format!("[exit] {line}")); } _ => {} } } }); - wait_for_server(host, port)?; + if let Err(err) = wait_for_server(host, port) { + let _ = child.kill(); + return Err(format_server_startup_error(&err, &startup_logs)); + } Ok(ServerRuntime { url, child }) } @@ -217,18 +975,20 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result { server_http_url.clone() }; - let sidecar = app + let mut sidecar = app .shell() .sidecar("claude-sidecar") - .map_err(|err| format!("resolve sidecar: {err}"))? - .env("ADAPTER_SERVER_URL", &server_ws_url) - .args([ - "adapters", - "--app-root", - &app_root_arg, - "--feishu", - "--telegram", - ]); + .map_err(|err| format!("resolve sidecar: {err}"))?; + for (key, value) in terminal_environment(&default_shell()) { + sidecar = sidecar.env(key, value); + } + let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([ + "adapters", + "--app-root", + &app_root_arg, + "--feishu", + "--telegram", + ]); let (mut rx, child) = sidecar .spawn() @@ -293,26 +1053,193 @@ fn stop_adapters_sidecar(app: &AppHandle) { } } +#[cfg(target_os = "windows")] +fn kill_windows_sidecars() { + for image_name in [ + "claude-sidecar-x86_64-pc-windows-msvc.exe", + "claude-sidecar-aarch64-pc-windows-msvc.exe", + "claude-sidecar.exe", + ] { + let _ = StdCommand::new("taskkill") + .args(["/F", "/T", "/IM", image_name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[cfg(test)] +mod tests { + use super::{ + decode_terminal_output, default_utf8_locale, ensure_utf8_locale, + has_meaningful_intersection, is_persistable_window_state, parse_env_block, + StoredWindowState, + }; + use std::collections::HashMap; + + #[test] + fn window_state_rejects_too_small_sizes() { + let valid = StoredWindowState { + x: 100, + y: 100, + width: 1200, + height: 800, + maximized: false, + }; + let too_narrow = StoredWindowState { + width: 959, + ..valid.clone() + }; + let too_short = StoredWindowState { + height: 639, + ..valid.clone() + }; + + assert!(is_persistable_window_state(&valid)); + assert!(!is_persistable_window_state(&too_narrow)); + assert!(!is_persistable_window_state(&too_short)); + } + + #[test] + fn window_state_requires_visible_monitor_intersection() { + let state = StoredWindowState { + x: 100, + y: 100, + width: 1200, + height: 800, + maximized: false, + }; + + assert!(has_meaningful_intersection(&state, 0, 0, 1920, 1080)); + assert!(!has_meaningful_intersection( + &StoredWindowState { + x: -1200, + y: 100, + ..state.clone() + }, + 0, + 0, + 1920, + 1080, + )); + assert!(!has_meaningful_intersection( + &StoredWindowState { + x: 1900, + y: 100, + ..state + }, + 0, + 0, + 1920, + 1080, + )); + } + + #[test] + fn terminal_output_decoder_preserves_split_chinese_characters() { + let mut pending = Vec::new(); + let bytes = "安装 Skills 成功\n".as_bytes(); + + assert_eq!(decode_terminal_output(&mut pending, &bytes[..2]), ""); + assert_eq!(decode_terminal_output(&mut pending, &bytes[2..4]), "安"); + assert_eq!( + decode_terminal_output(&mut pending, &bytes[4..]), + "装 Skills 成功\n" + ); + assert!(pending.is_empty()); + } + + #[test] + fn terminal_output_decoder_keeps_incomplete_suffix_pending() { + let mut pending = Vec::new(); + let bytes = "中文".as_bytes(); + + assert_eq!(decode_terminal_output(&mut pending, &bytes[..4]), "中"); + assert_eq!(pending, bytes[3..4]); + assert_eq!(decode_terminal_output(&mut pending, &bytes[4..]), "文"); + assert!(pending.is_empty()); + } + + #[test] + fn parse_env_block_reads_nul_delimited_values() { + let env = + parse_env_block(b"PATH=/opt/homebrew/bin:/usr/bin\0NODE_PATH=/tmp/node\0EMPTY=\0"); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/opt/homebrew/bin:/usr/bin") + ); + assert_eq!(env.get("NODE_PATH").map(String::as_str), Some("/tmp/node")); + assert_eq!(env.get("EMPTY").map(String::as_str), Some("")); + } + + #[test] + fn terminal_environment_forces_utf8_locale_when_shell_uses_c_locale() { + let mut env = HashMap::from([ + ("LANG".to_string(), "C".to_string()), + ("LC_CTYPE".to_string(), "POSIX".to_string()), + ("LC_ALL".to_string(), "C".to_string()), + ]); + + ensure_utf8_locale(&mut env); + + assert_eq!( + env.get("LANG").map(String::as_str), + Some(default_utf8_locale()) + ); + assert_eq!( + env.get("LC_CTYPE").map(String::as_str), + Some(default_utf8_locale()) + ); + assert_eq!( + env.get("LC_ALL").map(String::as_str), + Some(default_utf8_locale()) + ); + } + + #[test] + fn terminal_environment_keeps_existing_utf8_locale() { + let mut env = HashMap::from([ + ("LANG".to_string(), "zh_CN.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "en_US.UTF8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ]); + + ensure_utf8_locale(&mut env); + + assert_eq!(env.get("LANG").map(String::as_str), Some("zh_CN.UTF-8")); + assert_eq!(env.get("LC_CTYPE").map(String::as_str), Some("en_US.UTF8")); + assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() .manage(ServerState::default()) .manage(AdapterState::default()) + .manage(TerminalState::default()) + .manage(AppExitState::default()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ get_server_url, - restart_adapters_sidecar + restart_adapters_sidecar, + prepare_for_update_install, + terminal_spawn, + terminal_write, + terminal_resize, + terminal_kill ]); // macOS: native menu bar (traffic-light overlay style) #[cfg(target_os = "macos")] let builder = builder .menu(|app| { - let about_item = MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha") - .build(app)?; + let about_item = + MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha").build(app)?; let settings_item = MenuItemBuilder::with_id("nav_settings", "设置...") .accelerator("CmdOrCtrl+,") .build(app)?; @@ -341,9 +1268,7 @@ pub fn run() { .select_all() .build()?; - let view_submenu = SubmenuBuilder::new(app, "View") - .fullscreen() - .build()?; + let view_submenu = SubmenuBuilder::new(app, "View").fullscreen().build()?; let window_submenu = SubmenuBuilder::new(app, "Window") .minimize() @@ -370,6 +1295,9 @@ pub fn run() { let app = builder .setup(|app| { + setup_system_tray(app)?; + restore_main_window_state(&app.handle()); + let state = app.state::(); let mut guard = state .0 @@ -399,10 +1327,44 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application"); - app.run(|app_handle, event| { - if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { + app.run(|app_handle, event| match event { + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if should_hide_to_tray(app_handle, &label) => { + api.prevent_close(); + save_main_window_state(app_handle); + if let Some(window) = app_handle.get_webview_window(&label) { + let _ = window.hide(); + } + } + RunEvent::WindowEvent { + label, + event: WindowEvent::Moved(_) | WindowEvent::Resized(_), + .. + } if label == MAIN_WINDOW_LABEL => { + save_main_window_state(app_handle); + } + #[cfg(target_os = "macos")] + RunEvent::Reopen { + has_visible_windows: false, + .. + } => { + show_main_window(app_handle); + } + RunEvent::ExitRequested { .. } => { + mark_app_quitting(app_handle); + save_main_window_state(app_handle); stop_server_sidecar(app_handle); stop_adapters_sidecar(app_handle); } + RunEvent::Exit => { + mark_app_quitting(app_handle); + save_main_window_state(app_handle); + stop_server_sidecar(app_handle); + stop_adapters_sidecar(app_handle); + } + _ => {} }); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b5899b12..8b656379 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json", "productName": "Claude Code Haha", - "version": "0.1.5", + "version": "0.2.0", "identifier": "com.claude-code-haha.desktop", "build": { "frontendDist": "../dist", diff --git a/desktop/src-tauri/windows-installer-hooks.nsh b/desktop/src-tauri/windows-installer-hooks.nsh index d33adbf1..0ff18713 100644 --- a/desktop/src-tauri/windows-installer-hooks.nsh +++ b/desktop/src-tauri/windows-installer-hooks.nsh @@ -1,6 +1,8 @@ !macro NSIS_HOOK_PREINSTALL - DetailPrint "Stopping running Claude Code Haha processes..." - nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe' + DetailPrint "Stopping running Claude Code Haha sidecars..." + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' + Pop $0 + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe' Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe' Pop $0 @@ -11,6 +13,10 @@ DetailPrint "Stopping running Claude Code Haha processes..." nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe' Pop $0 + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' + Pop $0 + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe' + Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe' Pop $0 Sleep 1000 diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index 72a39672..c7c850ee 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -8,6 +8,7 @@ import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' +import { useUIStore } from '../stores/uiStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -21,8 +22,11 @@ vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ providers: [], activeId: null, + presets: [], isLoading: false, + isPresetsLoading: false, fetchProviders: vi.fn(), + fetchPresets: vi.fn(), deleteProvider: vi.fn(), activateProvider: vi.fn(), activateOfficial: vi.fn(), @@ -79,6 +83,18 @@ const MOCK_AGENTS = [ isActive: false, overriddenBy: 'userSettings' as const, }, + { + agentType: 'telegram:pairing', + description: 'Plugin agent for Telegram pairing flows', + model: 'inherit', + modelDisplay: 'inherit', + tools: ['Read'], + systemPrompt: 'Pair Telegram access for the current workspace.', + color: 'cyan', + source: 'plugin' as const, + baseDir: '/Users/test/.claude/plugins/cache/telegram', + isActive: true, + }, ] const MOCK_SKILL_DETAIL = { @@ -132,6 +148,7 @@ describe('Settings > Agents tab', () => { activeTabId: 'session-1', tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }], }) + useUIStore.setState({ pendingSettingsTab: null }) useSessionStore.setState({ sessions: [ { @@ -164,6 +181,7 @@ describe('Settings > Agents tab', () => { isLoading: false, error: null, selectedAgent: null, + selectedAgentReturnTab: 'agents', fetchAgents: noopFetch, selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }), }) @@ -236,8 +254,10 @@ describe('Settings > Agents tab', () => { expect(screen.getAllByText('User').length).toBeGreaterThan(0) expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0) expect(screen.getAllByText('Project').length).toBeGreaterThan(0) + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) expect(screen.getByText('code-reviewer')).toBeInTheDocument() expect(screen.getByText('Writes technical documentation')).toBeInTheDocument() + expect(screen.getByText('telegram:pairing')).toBeInTheDocument() expect(screen.getByText('Overridden by User')).toBeInTheDocument() }) @@ -296,6 +316,29 @@ describe('Settings > Agents tab', () => { expect(screen.getByText('doc-writer')).toBeInTheDocument() expect(screen.getByText('plain-agent')).toBeInTheDocument() }) + + it('returns to plugins tab when agent detail was opened from plugins', () => { + useAgentStore.setState({ + allAgents: MOCK_AGENTS, + activeAgents: MOCK_AGENTS.filter((agent) => agent.isActive), + isLoading: false, + selectedAgent: MOCK_AGENTS[0], + selectedAgentReturnTab: 'plugins', + fetchAgents: noopFetch, + selectAgent: (agent) => + useAgentStore.setState({ + selectedAgent: agent, + selectedAgentReturnTab: agent ? 'plugins' : 'agents', + }), + }) + + render() + switchToAgentsTab() + + fireEvent.click(screen.getByText('Back to list')) + + expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + }) }) describe('Settings > Skills tab', () => { diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx new file mode 100644 index 00000000..e5827770 --- /dev/null +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { Settings } from '../pages/Settings' +import { useSettingsStore } from '../stores/settingsStore' +import { useUIStore } from '../stores/uiStore' + +const diagnosticsApiMock = vi.hoisted(() => ({ + getStatus: vi.fn(), + getEvents: vi.fn(), + exportBundle: vi.fn(), + openLogDir: vi.fn(), + clear: vi.fn(), +})) + +vi.mock('../api/diagnostics', () => ({ + diagnosticsApi: diagnosticsApiMock, +})) + +vi.mock('../stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + activeId: null, + hasLoadedProviders: true, + presets: [], + isLoading: false, + isPresetsLoading: false, + fetchProviders: vi.fn(), + fetchPresets: vi.fn(), + deleteProvider: vi.fn(), + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), + }), +})) + +vi.mock('../api/providers', () => ({ + providersApi: { + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + }, +})) + +vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ + ClaudeOfficialLogin: () =>
, +})) + +vi.mock('../pages/AdapterSettings', () => ({ + AdapterSettings: () =>
, +})) + +vi.mock('../stores/agentStore', () => ({ + useAgentStore: () => ({ + activeAgents: [], + allAgents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: vi.fn(), + selectAgent: vi.fn(), + }), +})) + +vi.mock('../stores/skillStore', () => ({ + useSkillStore: () => ({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: vi.fn(), + fetchSkillDetail: vi.fn(), + clearSelection: vi.fn(), + }), +})) + +vi.mock('../components/chat/CodeViewer', () => ({ + CodeViewer: ({ code }: { code: string }) =>
{code}
, +})) + +describe('Settings > Diagnostics tab', () => { + beforeEach(() => { + diagnosticsApiMock.getStatus.mockResolvedValue({ + logDir: '/tmp/claude/cc-haha/diagnostics', + diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl', + runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log', + exportDir: '/tmp/claude/cc-haha/diagnostics/exports', + retentionDays: 7, + maxBytes: 50 * 1024 * 1024, + totalBytes: 4096, + eventCount: 2, + recentErrorCount: 1, + lastEventAt: '2026-05-02T00:00:00.000Z', + }) + diagnosticsApiMock.getEvents.mockResolvedValue({ + events: [{ + id: 'event-1', + timestamp: '2026-05-02T00:00:00.000Z', + type: 'cli_start_failed', + severity: 'error', + summary: 'CLI exited during startup with code 1', + sessionId: 'session-1', + }], + }) + diagnosticsApiMock.exportBundle.mockResolvedValue({ + bundle: { + path: '/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz', + fileName: 'cc-haha-diagnostics.tar.gz', + bytes: 1024, + }, + }) + diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true }) + diagnosticsApiMock.clear.mockResolvedValue({ ok: true }) + + useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ pendingSettingsTab: null, toasts: [] }) + }) + + it('shows diagnostics status, actions, and recent events', async () => { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + + expect(await screen.findByText('Log directory')).toBeInTheDocument() + expect(screen.getByText('/tmp/claude/cc-haha/diagnostics')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Export Bundle/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument() + expect(screen.getByText('cli_start_failed')).toBeInTheDocument() + expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument() + }) + + it('exports a diagnostics bundle from the settings page', async () => { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Export Bundle/i })) + + await waitFor(() => { + expect(diagnosticsApiMock.exportBundle).toHaveBeenCalled() + }) + expect(await screen.findByText('/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz')).toBeInTheDocument() + }) + + it('copies the recent error summary with the legacy clipboard fallback', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }) + const execCommand = vi.mocked(document.execCommand) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), + }, + }) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Copy Error Summary/i })) + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) + const toasts = useUIStore.getState().toasts + expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.') + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) +}) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 2f7ad26b..e7918317 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1,9 +1,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useSettingsStore } from '../stores/settingsStore' +import { useUIStore } from '../stores/uiStore' +import { useUpdateStore } from '../stores/updateStore' +import type { SavedProvider } from '../types/provider' +import type { ProviderPreset } from '../types/providerPreset' + +const MOCK_DELETE_PROVIDER = vi.fn() +const MOCK_GET_SETTINGS = vi.fn() +const MOCK_UPDATE_SETTINGS = vi.fn() +const providerStoreState = { + providers: [] as SavedProvider[], + activeId: null as string | null, + hasLoadedProviders: true, + presets: [] as ProviderPreset[], + isLoading: false, + isPresetsLoading: false, + fetchProviders: vi.fn(), + fetchPresets: vi.fn(), + deleteProvider: MOCK_DELETE_PROVIDER, + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), +} vi.mock('../api/agents', () => ({ agentsApi: { @@ -12,19 +37,18 @@ vi.mock('../api/agents', () => ({ })) vi.mock('../stores/providerStore', () => ({ - useProviderStore: () => ({ - providers: [], - activeId: null, - isLoading: false, - fetchProviders: vi.fn(), - deleteProvider: vi.fn(), - activateProvider: vi.fn(), - activateOfficial: vi.fn(), - testProvider: vi.fn(), - createProvider: vi.fn(), - updateProvider: vi.fn(), - testConfig: vi.fn(), - }), + useProviderStore: () => providerStoreState, +})) + +vi.mock('../api/providers', () => ({ + providersApi: { + getSettings: MOCK_GET_SETTINGS, + updateSettings: MOCK_UPDATE_SETTINGS, + }, +})) + +vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ + ClaudeOfficialLogin: () =>
, })) vi.mock('../pages/AdapterSettings', () => ({ @@ -62,12 +86,55 @@ vi.mock('../components/chat/CodeViewer', () => ({ describe('Settings > General tab', () => { beforeEach(() => { + MOCK_DELETE_PROVIDER.mockReset() + MOCK_GET_SETTINGS.mockResolvedValue({}) + MOCK_UPDATE_SETTINGS.mockResolvedValue({}) + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = true + providerStoreState.presets = [] + providerStoreState.isLoading = false + providerStoreState.isPresetsLoading = false + providerStoreState.fetchProviders = vi.fn() + providerStoreState.fetchPresets = vi.fn() + providerStoreState.activateProvider = vi.fn() + providerStoreState.activateOfficial = vi.fn() + providerStoreState.testProvider = vi.fn() + providerStoreState.createProvider = vi.fn() + providerStoreState.updateProvider = vi.fn() + providerStoreState.testConfig = vi.fn() + useSettingsStore.setState({ locale: 'en', + thinkingEnabled: true, skipWebFetchPreflight: true, + webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, + setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { + useSettingsStore.setState({ thinkingEnabled: enabled }) + }), setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ skipWebFetchPreflight: enabled }) }), + setWebSearch: vi.fn().mockImplementation(async (webSearch) => { + useSettingsStore.setState({ webSearch }) + }), + }) + + useUIStore.setState({ pendingSettingsTab: null }) + useUpdateStore.setState({ + status: 'idle', + availableVersion: null, + releaseNotes: null, + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: false, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), }) }) @@ -90,4 +157,237 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false) }) + + it('lets the user disable thinking mode for new sessions', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const toggle = screen.getByLabelText('Enable thinking mode') + expect(toggle).toBeChecked() + fireEvent.click(toggle) + + expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false) + }) + + it('saves WebSearch fallback provider settings', () => { + render() + + fireEvent.click(screen.getByText('General')) + + fireEvent.click(screen.getByRole('button', { name: 'Tavily' })) + fireEvent.change(screen.getByLabelText('Tavily API key'), { + target: { value: 'tvly-test-key' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({ + mode: 'tavily', + tavilyApiKey: 'tvly-test-key', + braveApiKey: '', + }) + }) + + it('links to WebSearch provider API key dashboards', () => { + render() + + fireEvent.click(screen.getByText('General')) + + expect(screen.getByRole('link', { name: 'Get Tavily API key' })).toHaveAttribute( + 'href', + 'https://app.tavily.com/home', + ) + expect(screen.getByRole('link', { name: 'Get Brave Search API key' })).toHaveAttribute( + 'href', + 'https://api-dashboard.search.brave.com/app/keys', + ) + }) + + it('keeps extension tabs available alongside the terminal tab', () => { + render() + + expect(screen.queryByText('Install')).not.toBeInTheDocument() + expect(screen.getByText('Terminal')).toBeInTheDocument() + expect(screen.getByText('MCP')).toBeInTheDocument() + expect(screen.getByText('Plugins')).toBeInTheDocument() + }) +}) + +describe('Settings > Providers tab', () => { + beforeEach(() => { + MOCK_DELETE_PROVIDER.mockReset() + MOCK_GET_SETTINGS.mockResolvedValue({}) + MOCK_UPDATE_SETTINGS.mockResolvedValue({}) + providerStoreState.providers = [ + { + id: 'provider-1', + name: 'MiniMax-M2.7-highspeed(openai)', + presetId: 'custom', + apiKey: '***', + baseUrl: 'https://api.minimaxi.com', + apiFormat: 'openai_chat', + models: { + main: 'MiniMax-M2.7-highspeed', + haiku: '', + sonnet: '', + opus: '', + }, + notes: '', + }, + ] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = true + }) + + it('does not query official OAuth status before providers finish loading', () => { + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = false + + render() + + expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() + }) + + it('shows official OAuth status only after official provider is confirmed active', () => { + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = true + + render() + + expect(screen.getByTestId('claude-official-login')).toBeInTheDocument() + }) + + it('requires confirmation before deleting a provider', async () => { + render() + + fireEvent.click(screen.getAllByText('Delete')[0]!) + + expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument() + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + + expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1') + }) + + it('uses the shared dropdown for API format in the provider form', () => { + providerStoreState.presets = [ + { + id: 'custom', + name: 'Custom', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + defaultModels: { + main: 'custom-main', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: '', + }, + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: /Add Provider/i })) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: /Anthropic Messages \(native\)/i })) + fireEvent.click(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i })) + + expect(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i })).toBeInTheDocument() + expect(within(dialog).getByText('Requests will be translated via the local proxy')).toBeInTheDocument() + }) + + it('hides the API key by default and reveals it from the eye button', () => { + providerStoreState.presets = [ + { + id: 'custom', + name: 'Custom', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + defaultModels: { + main: 'custom-main', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: '', + }, + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: /Add Provider/i })) + + const dialog = screen.getByRole('dialog') + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') + + expect(apiKeyInput).toHaveAttribute('type', 'password') + + fireEvent.click(within(dialog).getByRole('button', { name: 'Show API Key' })) + + expect(apiKeyInput).toHaveAttribute('type', 'text') + expect(within(dialog).getByRole('button', { name: 'Hide API Key' })).toBeInTheDocument() + }) +}) + +describe('Settings > About tab', () => { + beforeEach(() => { + useUIStore.setState({ pendingSettingsTab: 'about' }) + useUpdateStore.setState({ + status: 'available', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5\n\n- Fixed updater rendering\n- Added markdown support', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + }) + + it('renders release notes with markdown formatting', async () => { + render() + + expect(await screen.findByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument() + expect(screen.getByText('Fixed updater rendering')).toBeInTheDocument() + expect(screen.getByText('Added markdown support')).toBeInTheDocument() + }) + + it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => { + useUpdateStore.setState({ + status: 'downloading', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5', + progressPercent: 0, + downloadedBytes: 1536, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + + render() + + expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument() + expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument() + }) }) diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx new file mode 100644 index 00000000..418dd5c3 --- /dev/null +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { McpSettings } from '../pages/McpSettings' +import { useMcpStore } from '../stores/mcpStore' +import { useSessionStore } from '../stores/sessionStore' +import { useSettingsStore } from '../stores/settingsStore' + +describe('McpSettings', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Test Session', + createdAt: '', + modifiedAt: '', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + isLoading: false, + error: null, + selectedProjects: [], + availableProjects: [], + fetchSessions: vi.fn(), + createSession: vi.fn(), + deleteSession: vi.fn(), + renameSession: vi.fn(), + updateSessionTitle: vi.fn(), + setActiveSession: vi.fn(), + setSelectedProjects: vi.fn(), + }) + useMcpStore.setState({ + servers: [], + selectedServer: null, + isLoading: false, + error: null, + fetchServers: vi.fn(), + createServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + toggleServer: vi.fn(), + reconnectServer: vi.fn(), + refreshServerStatus: vi.fn(), + selectServer: vi.fn(), + }) + }) + + it('loads only global MCP servers on mount', () => { + const fetchServers = vi.fn() + useMcpStore.setState({ fetchServers }) + + render() + + expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project') + }) + + it('renders the empty state and add button', () => { + render() + + expect(screen.getByText('MCP servers')).toBeInTheDocument() + expect(screen.getByText('No MCP servers configured yet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument() + }) + + it('shows plugin and user MCP servers in grouped sections', () => { + useMcpStore.setState({ + servers: [ + { + name: 'plugin:telegram:telegram', + scope: 'dynamic', + transport: 'stdio', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'npx @telegram/mcp', + canEdit: false, + canRemove: false, + canReconnect: true, + canToggle: true, + config: { type: 'stdio', command: 'npx', args: ['@telegram/mcp'], env: {} }, + }, + { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + }, + ], + }) + + render() + + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) + expect(screen.getAllByText('User').length).toBeGreaterThan(0) + expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument() + expect(screen.getByText('global-user')).toBeInTheDocument() + }) + + it('starts background status refresh after the fast list render', async () => { + const server = { + name: 'deepwiki', + scope: 'user', + transport: 'http', + enabled: true, + status: 'checking' as const, + statusLabel: 'Checking', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} }, + } + const refreshServerStatus = vi.fn().mockResolvedValue({ + ...server, + status: 'connected' as const, + statusLabel: 'Connected', + }) + + useMcpStore.setState({ + servers: [server], + refreshServerStatus, + }) + + render() + + expect(screen.getByText('Checking')).toBeInTheDocument() + + await waitFor(() => { + expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project') + }) + }) + + it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => { + const deleteServer = vi.fn().mockResolvedValue(undefined) + const server = { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + } as const + + useMcpStore.setState({ + servers: [server], + deleteServer, + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open global-user' })) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /uninstall/i })) + }) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete MCP server')).toBeInTheDocument() + expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + }) + + expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project') + }) + + it('uses the active cwd when toggling a server', async () => { + const toggleServer = vi.fn().mockResolvedValue(undefined) + const server = { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + } as const + + useMcpStore.setState({ + servers: [server], + toggleServer, + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + }) + + expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project') + }) + + it('shows reconnecting status immediately in the detail view', async () => { + let resolveReconnect: ((value: typeof server) => void) | null = null + const server = { + name: 'plugin:telegram:telegram', + scope: 'dynamic', + transport: 'stdio', + enabled: true, + status: 'failed' as 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking', + statusLabel: 'Unavailable', + statusDetail: 'Timed out' as string | undefined, + configLocation: '/tmp/config', + summary: 'bun run start', + canEdit: false, + canRemove: false, + canReconnect: true, + canToggle: true, + config: { type: 'stdio' as const, command: 'bun', args: ['run', 'start'], env: {} }, + } + const reconnectServer = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveReconnect = resolve + })) + + useMcpStore.setState({ + servers: [server], + reconnectServer, + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open plugin:telegram:telegram' })) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /reconnect/i })) + }) + + expect(screen.getAllByText('Reconnecting...').length).toBeGreaterThan(0) + expect(reconnectServer).toHaveBeenCalledWith(server, '/workspace/project') + + await act(async () => { + resolveReconnect?.({ + ...server, + status: 'connected', + statusLabel: 'Connected', + statusDetail: undefined, + }) + }) + }) +}) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index ee04748b..6372070d 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1,8 +1,11 @@ -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { skillsApi } from '../api/skills' +import { mcpApi } from '../api/mcp' +import { sessionsApi } from '../api/sessions' +import { useUIStore } from '../stores/uiStore' vi.mock('../api/skills', () => ({ skillsApi: { @@ -10,6 +13,58 @@ vi.mock('../api/skills', () => ({ }, })) +vi.mock('../api/mcp', () => ({ + mcpApi: { + list: vi.fn(async () => ({ servers: [] })), + status: vi.fn(async (name: string) => ({ + server: { + name, + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: 'User', + summary: 'https://mcp.example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://mcp.example.com/mcp', headers: {} }, + }, + })), + }, +})) + +vi.mock('../api/sessions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sessionsApi: { + ...actual.sessionsApi, + getInspection: vi.fn(async () => ({ + active: true, + status: { + sessionId: 'status-panel-session', + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'kimi-k2.6', + version: '999.0.0-local', + apiKeySource: 'ANTHROPIC_API_KEY', + outputStyle: 'default', + mcpServers: [ + { name: 'deepwiki', status: 'connected' }, + { name: 'chatLog', status: 'failed' }, + ], + tools: [{ name: 'Read' }, { name: 'Bash' }], + slashCommandCount: 3, + }, + })), + }, + } +}) + // Import all pages import { EmptySession } from '../pages/EmptySession' import { ActiveSession } from '../pages/ActiveSession' @@ -21,9 +76,14 @@ import { ToolInspection } from '../pages/ToolInspection' import { Sidebar } from '../components/layout/Sidebar' import { UserMessage } from '../components/chat/UserMessage' import { useChatStore } from '../stores/chatStore' +import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) +}) + /** * Core rendering tests: content-only pages must render without crashing * and contain key structural elements from the prototype. @@ -58,6 +118,12 @@ describe('Content-only pages render without errors', () => { }) expect(await screen.findByText('/lark-mail')).toBeInTheDocument() + expect(screen.getByText('/mcp')).toBeInTheDocument() + expect(screen.getByText('/skills')).toBeInTheDocument() + expect(screen.getByText('/help')).toBeInTheDocument() + expect(screen.getByText('/plugin')).toBeInTheDocument() + expect(screen.getByText('/context')).toBeInTheDocument() + expect(screen.queryByText('/plugins')).not.toBeInTheDocument() expect(screen.queryByText('/internal-only')).not.toBeInTheDocument() }) @@ -201,6 +267,340 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession opens a local /mcp panel and clicking an item routes to settings', async () => { + const SESSION_ID = 'mcp-panel-session' + const sendMessage = vi.fn() + vi.mocked(mcpApi.list).mockResolvedValueOnce({ + servers: [ + { + name: 'deepwiki', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://mcp.deepwiki.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://mcp.deepwiki.com/mcp', headers: {} }, + }, + ], + }) + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: '/mcp', selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(await screen.findByText('Available MCP tools')).toBeInTheDocument() + fireEvent.click(screen.getByText('deepwiki')) + expect(useTabStore.getState().activeTabId).toBe('__settings__') + expect(useUIStore.getState().pendingSettingsTab).toBe('mcp') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession opens a local /skills panel from the fallback slash commands', async () => { + const SESSION_ID = 'skills-panel-session' + const sendMessage = vi.fn() + vi.mocked(skillsApi.list).mockResolvedValueOnce({ + skills: [ + { + name: 'lark-mail', + description: 'Draft, send, and search emails', + source: 'user', + userInvocable: true, + contentLength: 120, + hasDirectory: true, + }, + ], + }) + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(await screen.findByText('Available skills')).toBeInTheDocument() + expect(screen.getByText('/lark-mail')).toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession routes /plugin to Settings > Plugins instead of sending a chat message', () => { + const SESSION_ID = 'plugin-panel-session' + const sendMessage = vi.fn() + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(useTabStore.getState().activeTabId).toBe('__settings__') + expect(useUIStore.getState().pendingSettingsTab).toBe('plugins') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession routes /help to the local command panel', () => { + const SESSION_ID = 'help-panel-session' + const sendMessage = vi.fn() + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [ + { name: 'cost', description: 'Show token usage and costs' }, + ...Array.from({ length: 14 }, (_, index) => ({ + name: `extra-${index + 1}`, + description: `Extra command ${index + 1}`, + })), + ], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: '/help', selectionStart: 5 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(screen.getByText('Slash commands')).toBeInTheDocument() + expect(screen.getByText('/clear')).toBeInTheDocument() + expect(screen.getByText('/cost')).toBeInTheDocument() + expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession /status inspector uses theme tokens instead of fixed light colors', async () => { + const SESSION_ID = 'status-panel-session' + const sendMessage = vi.fn() + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + const { container } = render() + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: '/status', selectionStart: 7 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(await screen.findByText('Session inspector')).toBeInTheDocument() + expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { includeContext: false }) + expect(container.innerHTML).toContain('bg-[var(--color-inspector-surface)]') + expect(container.innerHTML).not.toContain('bg-[#fbfaf6]') + expect(container.innerHTML).not.toContain('bg-[#f4f2ed]') + expect(container.innerHTML).not.toContain('border-[#d8b3a8]') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('AgentTeams renders team strip and members', () => { const { container } = render() expect(container.innerHTML).toContain('Architect') @@ -272,7 +672,8 @@ describe('Design system compliance', () => { html.includes('C47A5A') || html.includes('8F482F') || html.includes('var(--color-brand)') || - html.includes('bg-[var(--color-brand)]'), + html.includes('bg-[var(--color-brand)]') || + html.includes('var(--gradient-btn-primary)'), ).toBe(true) unmount() } diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx new file mode 100644 index 00000000..11e20e49 --- /dev/null +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -0,0 +1,500 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { Settings } from '../pages/Settings' +import { usePluginStore } from '../stores/pluginStore' +import { useSettingsStore } from '../stores/settingsStore' +import { useSessionStore } from '../stores/sessionStore' +import { useUIStore } from '../stores/uiStore' + +const MOCK_FETCH_SKILLS = vi.fn() +const MOCK_FETCH_SKILL_DETAIL = vi.fn() +const MOCK_FETCH_AGENTS = vi.fn() +const MOCK_FETCH_SERVERS = vi.fn() + +vi.mock('../api/agents', () => ({ + agentsApi: { + list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }), + }, +})) + +vi.mock('../stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + activeId: null, + presets: [], + isLoading: false, + isPresetsLoading: false, + fetchProviders: vi.fn(), + fetchPresets: vi.fn(), + deleteProvider: vi.fn(), + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), + }), +})) + +vi.mock('../pages/AdapterSettings', () => ({ + AdapterSettings: () =>
Adapter Settings Mock
, +})) + +vi.mock('../stores/agentStore', () => ({ + useAgentStore: Object.assign((selector?: (state: any) => unknown) => { + const state = { + activeAgents: [], + allAgents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: MOCK_FETCH_AGENTS, + selectAgent: vi.fn(), + } + return selector ? selector(state) : state + }, { + getState: () => ({ + activeAgents: [], + allAgents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: MOCK_FETCH_AGENTS, + selectAgent: vi.fn(), + }), + }), +})) + +vi.mock('../stores/skillStore', () => ({ + useSkillStore: Object.assign((selector?: (state: any) => unknown) => { + const state = { + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: MOCK_FETCH_SKILLS, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: vi.fn(), + } + return selector ? selector(state) : state + }, { + getState: () => ({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: MOCK_FETCH_SKILLS, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: vi.fn(), + }), + }), +})) + +vi.mock('../stores/mcpStore', () => ({ + useMcpStore: Object.assign((selector?: (state: any) => unknown) => { + const state = { + servers: [], + selectedServer: null, + isLoading: false, + error: null, + fetchServers: MOCK_FETCH_SERVERS, + createServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + toggleServer: vi.fn(), + reconnectServer: vi.fn(), + selectServer: vi.fn(), + } + return selector ? selector(state) : state + }, { + getState: () => ({ + servers: [], + selectedServer: null, + isLoading: false, + error: null, + fetchServers: MOCK_FETCH_SERVERS, + createServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + toggleServer: vi.fn(), + reconnectServer: vi.fn(), + selectServer: vi.fn(), + }), + }), +})) + +const noop = vi.fn() + +function switchToPluginsTab() { + fireEvent.click(screen.getByText('Plugins')) +} + +describe('Settings > Plugins tab', () => { + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ pendingSettingsTab: null }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Active session', + createdAt: '2026-04-20T00:00:00.000Z', + modifiedAt: '2026-04-20T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + isLoading: false, + error: null, + }) + usePluginStore.setState({ + plugins: [], + marketplaces: [], + summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 }, + selectedPlugin: null, + lastReloadSummary: null, + isLoading: false, + isDetailLoading: false, + isApplying: false, + error: null, + fetchPlugins: noop, + fetchPluginDetail: noop, + reloadPlugins: vi.fn().mockResolvedValue({ + enabled: 1, + disabled: 0, + skills: 2, + agents: 1, + hooks: 0, + mcpServers: 1, + lspServers: 0, + errors: 0, + }), + enablePlugin: vi.fn().mockResolvedValue('enabled'), + disablePlugin: vi.fn().mockResolvedValue('disabled'), + updatePlugin: vi.fn().mockResolvedValue('updated'), + uninstallPlugin: vi.fn().mockResolvedValue('uninstalled'), + clearSelection: vi.fn(), + }) + }) + + it('renders plugin browser summary and grouped cards', () => { + usePluginStore.setState({ + plugins: [ + { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: true, + hasErrors: false, + isBuiltin: false, + version: '1.2.3', + description: 'GitHub integration', + authorName: 'Anthropic', + componentCounts: { + commands: 1, + agents: 1, + skills: 2, + hooks: 0, + mcpServers: 1, + lspServers: 0, + }, + errors: [], + }, + { + id: 'pyright-lsp@claude-plugins-official', + name: 'pyright-lsp', + marketplace: 'claude-plugins-official', + scope: 'project', + enabled: false, + hasErrors: true, + isBuiltin: false, + description: 'Python language tooling', + componentCounts: { + commands: 0, + agents: 0, + skills: 0, + hooks: 0, + mcpServers: 0, + lspServers: 1, + }, + errors: ['Executable not found in $PATH'], + }, + ], + marketplaces: [ + { + name: 'claude-plugins-official', + source: 'github:anthropics/claude-plugins-official', + autoUpdate: true, + installedCount: 2, + }, + ], + summary: { total: 2, enabled: 1, errorCount: 1, marketplaceCount: 1 }, + }) + + render() + switchToPluginsTab() + + expect(screen.getByText('Browse installed plugins')).toBeInTheDocument() + expect(screen.getByText('Plugin Manager')).toBeInTheDocument() + expect(screen.getAllByText('Needs attention').length).toBeGreaterThan(0) + expect(screen.getByText('github')).toBeInTheDocument() + expect(screen.getByText('Python language tooling')).toBeInTheDocument() + expect(screen.getByText('Known marketplaces')).toBeInTheDocument() + }) + + it('renders plugin detail with bundled capability sections', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: true, + hasErrors: false, + isBuiltin: false, + version: '1.2.3', + description: 'GitHub integration', + authorName: 'Anthropic', + installPath: '/Users/test/.claude/plugins/cache/github', + componentCounts: { + commands: 1, + agents: 1, + skills: 2, + hooks: 1, + mcpServers: 1, + lspServers: 0, + }, + capabilities: { + commands: ['review-pr'], + agents: ['pr-reviewer'], + skills: ['commit', 'create-pr'], + hooks: ['SessionStart'], + mcpServers: ['github-api'], + lspServers: [], + }, + commandEntries: [ + { + name: 'review-pr', + description: 'Review the current pull request.', + }, + ], + agentEntries: [ + { + name: 'pr-reviewer', + description: 'Review pull request quality and risk.', + }, + ], + hookEntries: [ + { + event: 'SessionStart', + matcher: 'Write', + actions: ['echo preparing plugin runtime'], + }, + ], + skillEntries: [ + { + name: 'create-pr', + description: 'Create a pull request from the current branch.', + }, + { + name: 'commit', + description: 'Commit the current staged changes.', + version: '1.0.0', + }, + ], + mcpServerEntries: [ + { + name: 'plugin:github:github-api', + displayName: 'github-api', + transport: 'http', + summary: 'https://api.github.com/mcp', + }, + ], + errors: [], + }, + }) + + render() + switchToPluginsTab() + + expect(screen.getByText('Plugin Detail')).toBeInTheDocument() + expect(screen.getByText('GitHub integration')).toBeInTheDocument() + expect(screen.getByText('Bundled capabilities')).toBeInTheDocument() + expect(screen.getByText('/review-pr')).toBeInTheDocument() + expect(screen.getByText('Review pull request quality and risk.')).toBeInTheDocument() + expect(screen.getByText('echo preparing plugin runtime')).toBeInTheDocument() + expect(screen.getByText('Create a pull request from the current branch.')).toBeInTheDocument() + expect(screen.getByText('https://api.github.com/mcp')).toBeInTheDocument() + expect(screen.getByText('Apply changes')).toBeInTheDocument() + expect(screen.getByText('Uninstall')).toBeInTheDocument() + }) + + it('keeps plugin detail hook order stable while the selected plugin reloads', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'GitHub integration', + componentCounts: { + commands: 1, + agents: 0, + skills: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: ['review-pr'], + agents: [], + skills: [], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [ + { + name: 'review-pr', + description: 'Review the current pull request.', + }, + ], + agentEntries: [], + hookEntries: [], + skillEntries: [], + mcpServerEntries: [], + errors: [], + }, + }) + + const { container } = render() + switchToPluginsTab() + + expect(screen.getByText('GitHub integration')).toBeInTheDocument() + + act(() => { + usePluginStore.setState({ isDetailLoading: true }) + }) + + expect(container.querySelector('.animate-spin')).toBeInTheDocument() + }) + + it('navigates plugin skills into the shared Skills page flow', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'telegram@claude-plugins-official', + name: 'telegram', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: true, + hasErrors: false, + isBuiltin: false, + description: 'Telegram integration', + componentCounts: { + commands: 0, + agents: 0, + skills: 1, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: [], + agents: [], + skills: ['telegram:access'], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [], + agentEntries: [], + hookEntries: [], + skillEntries: [ + { + name: 'telegram:access', + displayName: 'access', + description: 'Manage Telegram access.', + pluginName: 'telegram', + }, + ], + mcpServerEntries: [], + errors: [], + }, + }) + + render() + switchToPluginsTab() + + fireEvent.click(screen.getByText('access')) + + expect(MOCK_FETCH_SKILL_DETAIL).toHaveBeenCalledWith('plugin', 'telegram:access', '/workspace/project', 'plugins') + }) + + it('disables shared navigation cards for disabled plugins', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'codex@openai-codex', + name: 'codex', + marketplace: 'openai-codex', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'Use Codex from Claude Code', + componentCounts: { + commands: 0, + agents: 1, + skills: 1, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: [], + agents: ['codex:codex-rescue'], + skills: ['codex:gpt-5-4-prompting'], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [], + agentEntries: [ + { + name: 'codex:codex-rescue', + displayName: 'codex-rescue', + description: 'Delegate to Codex.', + }, + ], + hookEntries: [], + skillEntries: [ + { + name: 'codex:gpt-5-4-prompting', + displayName: 'gpt-5-4-prompting', + description: 'Prompting guide.', + }, + ], + mcpServerEntries: [], + errors: [], + }, + }) + + render() + switchToPluginsTab() + + expect(screen.getAllByText('Enable this plugin and apply changes before opening its skills, agents, or MCP entries in the shared management pages.').length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: /codex-rescue/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /gpt-5-4-prompting/i })).toBeDisabled() + }) +}) diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 19e46203..d13a996d 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -7,6 +7,7 @@ import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore' +import { useUIStore } from '../stores/uiStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -18,8 +19,11 @@ vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ providers: [], activeId: null, + presets: [], isLoading: false, + isPresetsLoading: false, fetchProviders: vi.fn(), + fetchPresets: vi.fn(), deleteProvider: vi.fn(), activateProvider: vi.fn(), activateOfficial: vi.fn(), @@ -82,9 +86,11 @@ describe('Settings > Skills tab', () => { availableProjects: ['/workspace/project'], }) useTabStore.setState({ tabs: [], activeTabId: null }) + useUIStore.setState({ pendingSettingsTab: null }) useSkillStore.setState({ skills: [], selectedSkill: null, + selectedSkillReturnTab: 'skills', isLoading: false, isDetailLoading: false, error: null, @@ -115,6 +121,16 @@ describe('Settings > Skills tab', () => { contentLength: 200, hasDirectory: true, }, + { + name: 'telegram:access', + displayName: 'Telegram Access', + description: 'Plugin-provided access workflow', + source: 'plugin', + pluginName: 'telegram', + userInvocable: true, + contentLength: 280, + hasDirectory: true, + }, ], }) @@ -126,6 +142,8 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Total skills')).toBeInTheDocument() expect(screen.getByText('Alpha Skill')).toBeInTheDocument() expect(screen.getByText('Second skill description')).toBeInTheDocument() + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) + expect(screen.getByText('Telegram Access')).toBeInTheDocument() }) it('uses the active session workDir even when settings tab is focused', () => { @@ -190,6 +208,7 @@ describe('Settings > Skills tab', () => { ], skillRoot: '/tmp/alpha', }, + selectedSkillReturnTab: 'skills', }) render() @@ -202,4 +221,39 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Hello')).toBeInTheDocument() expect(screen.queryByText(/^---$/)).not.toBeInTheDocument() }) + + it('returns to plugins tab when skill detail was opened from plugins', () => { + useSkillStore.setState({ + selectedSkill: { + meta: { + name: 'telegram:access', + displayName: 'Access', + description: 'Plugin skill', + source: 'plugin', + userInvocable: true, + contentLength: 200, + hasDirectory: true, + }, + tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }], + files: [ + { + path: 'SKILL.md', + content: '# Access', + body: '# Access', + language: 'markdown', + isEntry: true, + }, + ], + skillRoot: '/tmp/telegram-access', + }, + selectedSkillReturnTab: 'plugins', + }) + + render() + switchToSkillsTab() + + fireEvent.click(screen.getByText('Back to list')) + + expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + }) }) diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 6b2e570e..883d14c7 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -1,7 +1,26 @@ -const DEFAULT_BASE_URL = 'http://127.0.0.1:3456' +const ENV_BASE_URL = + typeof import.meta !== 'undefined' && + typeof import.meta.env?.VITE_DESKTOP_SERVER_URL === 'string' && + import.meta.env.VITE_DESKTOP_SERVER_URL.length > 0 + ? import.meta.env.VITE_DESKTOP_SERVER_URL + : undefined + +const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456' let baseUrl = DEFAULT_BASE_URL +function getErrorMessage(status: number, body: unknown) { + if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') { + return body.message + } + + if (typeof body === 'string' && body.trim().length > 0) { + return body + } + + return `API error ${status}` +} + export function setBaseUrl(url: string) { baseUrl = url.replace(/\/$/, '') } @@ -19,7 +38,7 @@ export class ApiError extends Error { public status: number, public body: unknown, ) { - super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`) + super(getErrorMessage(status, body)) this.name = 'ApiError' } } @@ -31,7 +50,8 @@ async function request(method: string, path: string, body?: unknown, options? } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), options?.timeout ?? 30_000) + const timeoutMs = options?.timeout ?? 30_000 + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(url, { method, @@ -50,6 +70,9 @@ async function request(method: string, path: string, body?: unknown, options? return res.json() as Promise } catch (err) { clearTimeout(timeout) + if (controller.signal.aborted) { + throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) + } throw err } } diff --git a/desktop/src/api/diagnostics.ts b/desktop/src/api/diagnostics.ts new file mode 100644 index 00000000..3fabac21 --- /dev/null +++ b/desktop/src/api/diagnostics.ts @@ -0,0 +1,40 @@ +import { api } from './client' + +export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error' + +export type DiagnosticEvent = { + id: string + timestamp: string + type: string + severity: DiagnosticSeverity + summary: string + sessionId?: string + details?: unknown +} + +export type DiagnosticsStatus = { + logDir: string + diagnosticsPath: string + runtimeErrorsPath: string + exportDir: string + retentionDays: number + maxBytes: number + totalBytes: number + eventCount: number + recentErrorCount: number + lastEventAt: string | null +} + +export type DiagnosticsBundle = { + path: string + fileName: string + bytes: number +} + +export const diagnosticsApi = { + getStatus: () => api.get('/api/diagnostics/status'), + getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`), + exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }), + openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'), + clear: () => api.delete<{ ok: true }>('/api/diagnostics'), +} diff --git a/desktop/src/api/mcp.ts b/desktop/src/api/mcp.ts new file mode 100644 index 00000000..113aa81b --- /dev/null +++ b/desktop/src/api/mcp.ts @@ -0,0 +1,43 @@ +import { api } from './client' +import type { McpServerRecord, McpUpsertPayload } from '../types/mcp' + +export const mcpApi = { + list: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`) + }, + + status: (name: string, cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`) + }, + + create: (name: string, payload: McpUpsertPayload, cwd?: string) => { + return api.post<{ server: McpServerRecord }>('/api/mcp', { + name, + ...payload, + ...(cwd ? { cwd } : {}), + }) + }, + + update: (name: string, payload: McpUpsertPayload, cwd?: string) => { + return api.put<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}`, { + ...payload, + ...(cwd ? { cwd } : {}), + }) + }, + + remove: (name: string, scope: string, cwd?: string) => { + const query = new URLSearchParams({ scope }) + if (cwd) query.set('cwd', cwd) + return api.delete<{ ok: true }>(`/api/mcp/${encodeURIComponent(name)}?${query.toString()}`) + }, + + toggle: (name: string, cwd?: string) => { + return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, cwd ? { cwd } : {}) + }, + + reconnect: (name: string, cwd?: string) => { + return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {}) + }, +} diff --git a/desktop/src/api/plugins.ts b/desktop/src/api/plugins.ts new file mode 100644 index 00000000..56bddc8b --- /dev/null +++ b/desktop/src/api/plugins.ts @@ -0,0 +1,47 @@ +import { api } from './client' +import type { + PluginDetail, + PluginListResponse, + PluginReloadSummary, + PluginScope, +} from '../types/plugin' + +type PluginActionPayload = { + id: string + scope?: PluginScope + keepData?: boolean +} + +export const pluginsApi = { + list: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get(`/api/plugins${query}`) + }, + + detail: (id: string, cwd?: string) => { + const query = new URLSearchParams({ id }) + if (cwd) query.set('cwd', cwd) + return api.get<{ detail: PluginDetail }>(`/api/plugins/detail?${query.toString()}`) + }, + + enable: (payload: PluginActionPayload) => + api.post<{ ok: true; message: string }>('/api/plugins/enable', payload), + + disable: (payload: PluginActionPayload) => + api.post<{ ok: true; message: string }>('/api/plugins/disable', payload), + + update: (payload: PluginActionPayload) => + api.post<{ ok: true; message: string }>('/api/plugins/update', payload), + + uninstall: (payload: PluginActionPayload) => + api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload), + + reload: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.post<{ ok: true; summary: PluginReloadSummary }>( + `/api/plugins/reload${query}`, + undefined, + { timeout: 120_000 }, + ) + }, +} diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 6531a4a2..e19ac1b7 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -8,7 +8,7 @@ import type { TestProviderConfigInput, ProviderTestResult, } from '../types/provider' -import type { ProviderPreset } from '../config/providerPresets' +import type { ProviderPreset } from '../types/providerPreset' type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } type ProviderResponse = { provider: SavedProvider } @@ -33,6 +33,14 @@ export const providersApi = { return api.get('/api/providers/auth-status') }, + getSettings() { + return api.get>('/api/providers/settings') + }, + + updateSettings(settings: Record) { + return api.put<{ ok: true }>('/api/providers/settings', settings) + }, + create(input: CreateProviderInput) { return api.post('/api/providers', input) }, diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index c1a21c3d..2ec470b0 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -1,9 +1,31 @@ import { api } from './client' +import type { AgentTaskNotification } from '../types/chat' import type { SessionListItem, MessageEntry } from '../types/session' type SessionsResponse = { sessions: SessionListItem[]; total: number } -type MessagesResponse = { messages: MessageEntry[] } +type MessagesResponse = { + messages: MessageEntry[] + taskNotifications?: AgentTaskNotification[] +} type CreateSessionResponse = { sessionId: string } +export type SessionRewindResponse = { + target: { + targetUserMessageId: string + userMessageIndex: number + userMessageCount: number + } + conversation: { + messagesRemoved: number + removedMessageIds?: string[] + } + code: { + available: boolean + reason?: string + filesChanged: string[] + insertions: number + deletions: number + } +} export type RecentProject = { projectPath: string @@ -16,6 +38,203 @@ export type RecentProject = { sessionCount: number } +export type SessionUsageSnapshot = { + source?: 'current_process' | 'transcript' + totalCostUSD: number + costDisplay: string + hasUnknownModelCost: boolean + totalAPIDuration: number + totalDuration: number + totalLinesAdded: number + totalLinesRemoved: number + totalInputTokens: number + totalOutputTokens: number + totalCacheReadInputTokens: number + totalCacheCreationInputTokens: number + totalWebSearchRequests: number + models: Array<{ + model: string + displayName: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + webSearchRequests: number + costUSD: number + costDisplay: string + contextWindow: number + maxOutputTokens: number + }> +} + +export type SessionContextSnapshot = { + categories: Array<{ + name: string + tokens: number + color: string + isDeferred?: boolean + }> + totalTokens: number + maxTokens: number + rawMaxTokens: number + percentage: number + gridRows: Array> + model: string + memoryFiles: Array<{ path: string; type: string; tokens: number }> + mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }> + deferredBuiltinTools?: Array<{ name: string; tokens: number; isLoaded: boolean }> + systemTools?: Array<{ name: string; tokens: number }> + systemPromptSections?: Array<{ name: string; tokens: number }> + agents: Array<{ agentType: string; source: string; tokens: number }> + slashCommands?: { + totalCommands: number + includedCommands: number + tokens: number + } + skills?: { + totalSkills: number + includedSkills: number + tokens: number + skillFrontmatter: Array<{ name: string; source: string; tokens: number }> + } + messageBreakdown?: { + toolCallTokens: number + toolResultTokens: number + attachmentTokens: number + assistantMessageTokens: number + userMessageTokens: number + toolCallsByType: Array<{ name: string; callTokens: number; resultTokens: number }> + attachmentsByType: Array<{ name: string; tokens: number }> + } + apiUsage?: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number + } | null +} + +export type SessionInspectionResponse = { + active: boolean + status: { + sessionId: string + workDir: string + permissionMode: string + version?: string + cwd?: string + model?: string + apiKeySource?: string + outputStyle?: string + tools?: string[] + mcpServers?: Array<{ name: string; status: string }> + slashCommandCount?: number + skillCount?: number + } + usage?: SessionUsageSnapshot + context?: SessionContextSnapshot + contextEstimate?: SessionContextSnapshot + errors?: Record +} + +export type WorkspaceFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'untracked' + | 'copied' + | 'type_changed' + | 'unknown' + +export type WorkspaceChangedFile = { + path: string + oldPath?: string + status: WorkspaceFileStatus + additions: number + deletions: number +} + +export type WorkspaceStatusResult = { + state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: WorkspaceChangedFile[] + error?: string +} + +export type WorkspaceReadFileResult = { + state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' + path: string + previewType?: 'text' | 'image' + content?: string + dataUrl?: string + mimeType?: string + language: string + size: number + truncated?: boolean + readBytes?: number + error?: string +} + +export type WorkspaceTreeEntry = { + name: string + path: string + isDirectory: boolean +} + +export type WorkspaceTreeResult = { + state: 'ok' | 'missing' | 'error' + path: string + entries: WorkspaceTreeEntry[] + error?: string +} + +export type WorkspaceDiffResult = { + state: 'ok' | 'missing' | 'not_git_repo' | 'error' + path: string + diff?: string + error?: string +} + +export type SessionTurnCheckpoint = { + target: SessionRewindResponse['target'] + conversation?: SessionRewindResponse['conversation'] + code: SessionRewindResponse['code'] + workDir?: string +} + +export type SessionTurnCheckpointsResponse = { + checkpoints: SessionTurnCheckpoint[] +} + +export type TurnCheckpointDiffResult = WorkspaceDiffResult & { + target?: SessionRewindResponse['target'] + workDir?: string +} + +function buildWorkspacePath( + sessionId: string, + resource: 'status' | 'tree' | 'file' | 'diff', + workspacePath?: string, +) { + const query = new URLSearchParams() + if (typeof workspacePath === 'string' && workspacePath.length > 0) { + query.set('path', workspacePath) + } + + const qs = query.toString() + return `/api/sessions/${sessionId}/workspace/${resource}${qs ? `?${qs}` : ''}` +} + export const sessionsApi = { list(params?: { project?: string; limit?: number; offset?: number }) { const query = new URLSearchParams() @@ -54,4 +273,61 @@ export const sessionsApi = { getSlashCommands(sessionId: string) { return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`) }, + + getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number }) { + const query = options?.includeContext === undefined + ? '' + : `?includeContext=${options.includeContext ? '1' : '0'}` + return api.get(`/api/sessions/${sessionId}/inspection${query}`, { + timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000), + }) + }, + + getWorkspaceStatus(sessionId: string) { + return api.get(buildWorkspacePath(sessionId, 'status')) + }, + + getWorkspaceTree(sessionId: string, workspacePath = '') { + return api.get(buildWorkspacePath(sessionId, 'tree', workspacePath)) + }, + + getWorkspaceFile(sessionId: string, workspacePath: string) { + return api.get(buildWorkspacePath(sessionId, 'file', workspacePath)) + }, + + getWorkspaceDiff(sessionId: string, workspacePath: string) { + return api.get(buildWorkspacePath(sessionId, 'diff', workspacePath)) + }, + + getTurnCheckpoints(sessionId: string) { + return api.get(`/api/sessions/${sessionId}/turn-checkpoints`) + }, + + getTurnCheckpointDiff( + sessionId: string, + targetUserMessageId: string, + workspacePath: string, + userMessageIndex?: number, + ) { + const query = new URLSearchParams() + query.set('targetUserMessageId', targetUserMessageId) + if (Number.isInteger(userMessageIndex)) { + query.set('userMessageIndex', String(userMessageIndex)) + } + query.set('path', workspacePath) + return api.get( + `/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`, + ) + }, + + rewind(sessionId: string, body: { + targetUserMessageId?: string + userMessageIndex?: number + expectedContent?: string + dryRun?: boolean + }) { + return api.post(`/api/sessions/${sessionId}/rewind`, body, { + timeout: 60_000, + }) + }, } diff --git a/desktop/src/api/settings.ts b/desktop/src/api/settings.ts index b3c34d54..4255f943 100644 --- a/desktop/src/api/settings.ts +++ b/desktop/src/api/settings.ts @@ -1,6 +1,20 @@ import { api } from './client' import type { PermissionMode, UserSettings } from '../types/settings' +export type CliLauncherStatus = { + supported: boolean + command: string + installed: boolean + launcherPath: string + binDir: string + pathConfigured: boolean + pathInCurrentShell: boolean + availableInNewTerminals: boolean + needsTerminalRestart: boolean + configTarget: string | null + lastError: string | null +} + export const settingsApi = { getUser() { return api.get('/api/settings/user') @@ -17,4 +31,8 @@ export const settingsApi = { setPermissionMode(mode: PermissionMode) { return api.put<{ ok: true; mode: PermissionMode }>('/api/permissions/mode', { mode }) }, + + getCliLauncherStatus() { + return api.get('/api/settings/cli-launcher') + }, } diff --git a/desktop/src/api/skills.ts b/desktop/src/api/skills.ts index 18b0c878..c0816997 100644 --- a/desktop/src/api/skills.ts +++ b/desktop/src/api/skills.ts @@ -4,7 +4,7 @@ import type { SkillMeta, SkillDetail } from '../types/skill' export const skillsApi = { list: (cwd?: string) => { const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' - return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`) + return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 }) }, detail: (source: string, name: string, cwd?: string) => { @@ -14,6 +14,9 @@ export const skillsApi = { }) if (cwd) query.set('cwd', cwd) - return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`) + return api.get<{ detail: SkillDetail }>( + `/api/skills/detail?${query.toString()}`, + { timeout: 120_000 }, + ) }, } diff --git a/desktop/src/api/terminal.ts b/desktop/src/api/terminal.ts new file mode 100644 index 00000000..cd09a6fa --- /dev/null +++ b/desktop/src/api/terminal.ts @@ -0,0 +1,58 @@ +import { isTauriRuntime } from '../lib/desktopRuntime' + +export type TerminalSpawnResult = { + session_id: number + shell: string + cwd: string +} + +export type TerminalOutputPayload = { + session_id: number + data: string +} + +export type TerminalExitPayload = { + session_id: number + code: number + signal?: string | null +} + +type Unlisten = () => void + +async function invoke(command: string, args?: Record): Promise { + if (!isTauriRuntime()) { + throw new Error('Terminal is available in the desktop app runtime.') + } + const api = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return api.invoke(command, args) +} + +export const terminalApi = { + isAvailable: isTauriRuntime, + + spawn(input: { cols: number; rows: number; cwd?: string }) { + return invoke('terminal_spawn', input) + }, + + write(sessionId: number, data: string) { + return invoke('terminal_write', { sessionId, data }) + }, + + resize(sessionId: number, cols: number, rows: number) { + return invoke('terminal_resize', { sessionId, cols, rows }) + }, + + kill(sessionId: number) { + return invoke('terminal_kill', { sessionId }) + }, + + async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise { + const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return events.listen('terminal-output', (event) => handler(event.payload)) + }, + + async onExit(handler: (payload: TerminalExitPayload) => void): Promise { + const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return events.listen('terminal-exit', (event) => handler(event.payload)) + }, +} diff --git a/desktop/src/api/websocket.test.ts b/desktop/src/api/websocket.test.ts new file mode 100644 index 00000000..7481319e --- /dev/null +++ b/desktop/src/api/websocket.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./client', () => ({ + getBaseUrl: () => 'http://127.0.0.1:3456', +})) + +import { wsManager } from './websocket' + +type SocketHandler = (() => void) | ((event: { data: string }) => void) + +class FakeWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static instances: FakeWebSocket[] = [] + + readonly url: string + readyState = FakeWebSocket.CONNECTING + onopen: SocketHandler | null = null + onmessage: SocketHandler | null = null + onclose: SocketHandler | null = null + onerror: SocketHandler | null = null + sent: string[] = [] + + constructor(url: string) { + this.url = url + FakeWebSocket.instances.push(this) + } + + send(data: string) { + this.sent.push(data) + } + + close() { + this.readyState = FakeWebSocket.CLOSED + ;(this.onclose as (() => void) | null)?.() + } + + open() { + this.readyState = FakeWebSocket.OPEN + ;(this.onopen as (() => void) | null)?.() + } + + fail() { + this.readyState = FakeWebSocket.CLOSED + ;(this.onclose as (() => void) | null)?.() + } +} + +describe('wsManager reconnect buffering', () => { + const originalWebSocket = globalThis.WebSocket + + beforeEach(() => { + vi.useFakeTimers() + FakeWebSocket.instances = [] + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + wsManager.disconnectAll() + }) + + afterEach(() => { + wsManager.disconnectAll() + globalThis.WebSocket = originalWebSocket + vi.useRealTimers() + }) + + it('replays queued messages after an unexpected reconnect', async () => { + wsManager.connect('session-reconnect') + + const firstSocket = FakeWebSocket.instances[0] + expect(firstSocket?.url).toContain('/ws/session-reconnect') + + firstSocket!.open() + wsManager.send('session-reconnect', { type: 'user_message', content: 'first' }) + expect(firstSocket!.sent).toEqual([ + JSON.stringify({ type: 'user_message', content: 'first' }), + ]) + + firstSocket!.fail() + wsManager.send('session-reconnect', { type: 'user_message', content: 'queued while offline' }) + + await vi.advanceTimersByTimeAsync(1000) + + const secondSocket = FakeWebSocket.instances[1] + expect(secondSocket).toBeDefined() + secondSocket!.open() + + expect(secondSocket!.sent).toEqual([ + JSON.stringify({ type: 'user_message', content: 'queued while offline' }), + ]) + }) +}) diff --git a/desktop/src/api/websocket.ts b/desktop/src/api/websocket.ts index 44d9130b..2e10622b 100644 --- a/desktop/src/api/websocket.ts +++ b/desktop/src/api/websocket.ts @@ -27,7 +27,17 @@ class WebSocketManager { connect(sessionId: string) { const existing = this.connections.get(sessionId) - if (existing && !existing.intentionalClose) return + if ( + existing && + !existing.intentionalClose && + ( + existing.ws.readyState === WebSocket.OPEN || + existing.ws.readyState === WebSocket.CONNECTING || + existing.reconnectTimer !== null + ) + ) { + return + } const wsUrl = getBaseUrl().replace(/^http/, 'ws') const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) @@ -36,10 +46,10 @@ class WebSocketManager { ws, handlers: existing?.handlers ?? new Set(), reconnectTimer: null, - reconnectAttempt: 0, + reconnectAttempt: existing?.reconnectAttempt ?? 0, pingInterval: null, intentionalClose: false, - pendingMessages: [], + pendingMessages: existing?.pendingMessages ?? [], } this.connections.set(sessionId, conn) @@ -98,13 +108,27 @@ class WebSocketManager { } send(sessionId: string, message: ClientMessage) { - const conn = this.connections.get(sessionId) - if (!conn) return + let conn = this.connections.get(sessionId) + if (!conn) { + this.connect(sessionId) + conn = this.connections.get(sessionId) + if (!conn) return + } if (conn.ws.readyState === WebSocket.OPEN) { conn.ws.send(JSON.stringify(message)) - } else if (conn.ws.readyState === WebSocket.CONNECTING) { - conn.pendingMessages.push(message) + return + } + + conn.pendingMessages.push(message) + + if ( + conn.ws.readyState === WebSocket.CLOSED || + conn.ws.readyState === WebSocket.CLOSING + ) { + if (!conn.intentionalClose && !conn.reconnectTimer) { + this.scheduleReconnect(sessionId, conn) + } } } @@ -147,15 +171,8 @@ class WebSocketManager { conn.reconnectTimer = setTimeout(() => { if (this.connections.get(sessionId) === conn && !conn.intentionalClose) { - this.connections.delete(sessionId) + conn.reconnectTimer = null this.connect(sessionId) - // Migrate handlers to new connection - const newConn = this.connections.get(sessionId) - if (newConn) { - for (const handler of conn.handlers) { - newConn.handlers.add(handler) - } - } } }, delay) } diff --git a/desktop/src/components/chat/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx index 4a8686f1..b6d15d85 100644 --- a/desktop/src/components/chat/AskUserQuestion.test.tsx +++ b/desktop/src/components/chat/AskUserQuestion.test.tsx @@ -24,6 +24,7 @@ vi.mock('../../api/sessions', () => ({ import { AskUserQuestion } from './AskUserQuestion' import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' const ACTIVE_TAB = 'active-tab' @@ -31,6 +32,7 @@ const ACTIVE_TAB = 'active-tab' describe('AskUserQuestion', () => { beforeEach(() => { sendMock.mockReset() + useSettingsStore.setState({ locale: 'en' }) useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session', status: 'idle' }], diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 67006926..93694544 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -135,7 +135,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) { if (!activeQuestion) return null return ( -
-
-
- +
+
+
+ {!isStreaming && } {isStreaming && ( )}
-
- + +
) } + +function shouldUseDocumentLayout(content: string) { + const normalized = content.trim() + if (!normalized) return false + + if (/```/.test(normalized)) return true + if (/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|.+\|)/m.test(normalized)) return true + + const paragraphs = normalized + .split(/\n\s*\n/) + .map((chunk) => chunk.trim()) + .filter(Boolean) + + return paragraphs.length >= 2 || normalized.split('\n').filter((line) => line.trim()).length >= 8 +} diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 484dfda4..2d83e685 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -5,8 +5,13 @@ export type AttachmentPreview = { id?: string type: 'image' | 'file' name: string + path?: string data?: string previewUrl?: string + lineStart?: number + lineEnd?: number + note?: string + quote?: string } type Props = { @@ -35,7 +40,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } return ( <> -
+
{attachments.map((attachment, index) => { if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) { const src = attachment.previewUrl || attachment.data || '' @@ -80,18 +85,29 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } return (
- attach_file - {attachment.name} + + {attachment.lineStart ? 'chat_bubble' : 'description'} + + + {attachment.name} + {attachment.lineStart + ? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` + : ''} + {onRemove && attachment.id && ( )}
diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx new file mode 100644 index 00000000..acbcae60 --- /dev/null +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' + +const mocks = vi.hoisted(() => ({ + getGitInfo: vi.fn(), + search: vi.fn(), + browse: vi.fn(), + wsSend: vi.fn(), +})) + +vi.mock('../../api/sessions', () => ({ + sessionsApi: { + getGitInfo: mocks.getGitInfo, + }, +})) + +vi.mock('../../api/filesystem', () => ({ + filesystemApi: { + search: mocks.search, + browse: mocks.browse, + }, +})) + +vi.mock('../../api/websocket', () => ({ + wsManager: { + connect: vi.fn(), + disconnect: vi.fn(), + onMessage: vi.fn(() => () => {}), + clearHandlers: vi.fn(), + send: mocks.wsSend, + }, +})) + +vi.mock('../controls/PermissionModeSelector', () => ({ + PermissionModeSelector: () => , +})) + +vi.mock('../controls/ModelSelector', () => ({ + ModelSelector: () => , +})) + +import { ChatInput } from './ChatInput' +import { useChatStore } from '../../stores/chatStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { useTabStore } from '../../stores/tabStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' + +describe('ChatInput file mentions', () => { + const sessionId = 'session-file-mention' + const initialChatState = useChatStore.getInitialState() + const initialSessionState = useSessionStore.getInitialState() + const initialTabState = useTabStore.getInitialState() + const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState() + + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useChatStore.setState(initialChatState, true) + useSessionStore.setState(initialSessionState, true) + useTabStore.setState(initialTabState, true) + useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true) + + useTabStore.setState({ + activeTabId: sessionId, + tabs: [{ sessionId, title: 'Project', type: 'session', status: 'idle' }], + }) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 1, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + }], + activeSessionId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 }) + }) + + it('turns a selected @ file into a chip without corrupting the typed path', async () => { + mocks.search.mockResolvedValueOnce({ + currentPath: '/repo/backend/src', + parentPath: '/repo/backend', + query: 'conditions.py', + entries: [ + { name: 'conditions.py', path: '/repo/backend/src/conditions.py', isDirectory: false }, + ], + }) + + render() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + const mention = '@backend/src/conditions.py' + fireEvent.change(input, { + target: { + value: `${mention} 记一下这个文件讲了什么东西。`, + selectionStart: mention.length, + }, + }) + + fireEvent.click(await screen.findByText('conditions.py')) + + await waitFor(() => { + expect(input.value).toBe('记一下这个文件讲了什么东西。') + }) + expect(screen.getByText('conditions.py')).toBeInTheDocument() + + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: '记一下这个文件讲了什么东西。', + attachments: [{ + type: 'file', + name: 'conditions.py', + path: '/repo/backend/src/conditions.py', + lineStart: undefined, + lineEnd: undefined, + note: undefined, + quote: undefined, + }], + }) + const messages = useChatStore.getState().sessions[sessionId]?.messages ?? [] + expect(messages[messages.length - 1]).toMatchObject({ + type: 'user_text', + content: '记一下这个文件讲了什么东西。', + modelContent: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。', + attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }], + }) + }) +}) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index b9cff77a..4d7b66c7 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -1,9 +1,16 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' -import { useTabStore } from '../../stores/tabStore' +import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' +import { + formatWorkspaceReferencePrompt, + useWorkspaceChatContextStore, + type WorkspaceChatReference, +} from '../../stores/workspaceChatContextStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' @@ -12,11 +19,13 @@ import { AttachmentGallery } from './AttachmentGallery' import { ProjectContextChip } from '../shared/ProjectContextChip' import { DirectoryPicker } from '../shared/DirectoryPicker' import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' +import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { FALLBACK_SLASH_COMMANDS, findSlashTrigger, mergeSlashCommands, replaceSlashToken, + resolveSlashUiAction, } from './composerUtils' type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number } @@ -25,22 +34,44 @@ type Attachment = { id: string name: string type: 'image' | 'file' + path?: string mimeType?: string previewUrl?: string data?: string + lineStart?: number + lineEnd?: number + note?: string + quote?: string } type ChatInputProps = { variant?: 'default' | 'hero' + compact?: boolean } -export function ChatInput({ variant = 'default' }: ChatInputProps) { +const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] + +function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment { + return { + id: reference.id, + name: reference.name, + type: 'file', + path: reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + } +} + +export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [fileSearchOpen, setFileSearchOpen] = useState(false) + const [localSlashPanel, setLocalSlashPanel] = useState(null) const [atFilter, setAtFilter] = useState('') const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') @@ -57,21 +88,68 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] + const composerPrefill = sessionState?.composerPrefill ?? null const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) + const workspaceReferences = useWorkspaceChatContextStore( + (s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES, + ) + const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference) + const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference) + const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences) const isMemberSession = !!memberInfo const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false - const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) - const isHeroComposer = variant === 'hero' && !isMemberSession + const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0 + const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences))) + const isHeroComposer = variant === 'hero' && !isMemberSession && !compact + const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined + const composerAttachments = useMemo( + () => [ + ...attachments, + ...workspaceReferences.map(workspaceReferenceToAttachment), + ], + [attachments, workspaceReferences], + ) useEffect(() => { textareaRef.current?.focus() }, [isActive]) + useEffect(() => { + if (!composerPrefill) return + + setInput(composerPrefill.text) + setAttachments( + (composerPrefill.attachments ?? []) + .filter((attachment) => attachment.type === 'image' || attachment.data) + .map((attachment, index) => ({ + id: `rewind-prefill-${composerPrefill.nonce}-${index}`, + name: attachment.name, + type: attachment.type, + mimeType: attachment.mimeType, + previewUrl: attachment.type === 'image' ? attachment.data : undefined, + data: attachment.data, + })), + ) + setPlusMenuOpen(false) + setSlashMenuOpen(false) + setFileSearchOpen(false) + setSlashFilter('') + setAtFilter('') + setAtCursorPos(-1) + + requestAnimationFrame(() => { + const el = textareaRef.current + el?.focus() + const cursor = composerPrefill.text.length + el?.setSelectionRange(cursor, cursor) + }) + }, [composerPrefill]) + useEffect(() => { if (!activeTabId) { setGitInfo(null) @@ -126,6 +204,22 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { return () => document.removeEventListener('mousedown', handleClick) }, [slashMenuOpen]) + useEffect(() => { + if (!localSlashPanel) return + const handleClick = (event: MouseEvent) => { + if ( + slashMenuRef.current && + !slashMenuRef.current.contains(event.target as Node) && + textareaRef.current && + !textareaRef.current.contains(event.target as Node) + ) { + setLocalSlashPanel(null) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [localSlashPanel]) + useEffect(() => { if (!fileSearchOpen) return const handleClick = (event: MouseEvent) => { @@ -143,23 +237,35 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { return () => document.removeEventListener('mousedown', handleClick) }, [fileSearchOpen]) + const allSlashCommands = useMemo( + () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), + [slashCommands], + ) + const filteredCommands = useMemo(() => { - const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS) + const source = allSlashCommands if (!slashFilter) return source const lower = slashFilter.toLowerCase() return source.filter((command) => ( command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower) )) - }, [slashCommands, slashFilter]) + }, [allSlashCommands, slashFilter]) + + const exactSlashCommand = useMemo(() => { + const normalized = slashFilter.trim().toLowerCase() + if (!normalized) return null + return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null + }, [filteredCommands, slashFilter]) useEffect(() => { setSlashSelectedIndex(0) }, [slashFilter]) useEffect(() => { - if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) { - slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' }) + const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null + if (activeItem && typeof activeItem.scrollIntoView === 'function') { + activeItem.scrollIntoView({ block: 'nearest' }) } }, [slashMenuOpen, slashSelectedIndex]) @@ -204,7 +310,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { // Extract filter text after @ const filter = textBeforeCursor.slice(pos + 1) setAtFilter(filter) - setAtCursorPos(cursorPos) + setAtCursorPos(pos) setSlashMenuOpen(false) setFileSearchOpen(true) }, []) @@ -236,21 +342,81 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const handleSubmit = () => { const text = input.trim() - if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return + if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return - const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ + const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null + if (slashUiAction?.type === 'panel') { + setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName) + setInput('') + setSlashMenuOpen(false) + setFileSearchOpen(false) + setPlusMenuOpen(false) + return + } + + if (slashUiAction?.type === 'settings') { + useUIStore.getState().setPendingSettingsTab(slashUiAction.tab) + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + setInput('') + setSlashMenuOpen(false) + setFileSearchOpen(false) + setPlusMenuOpen(false) + return + } + + const workspaceReferencePrompt = !isMemberSession + ? formatWorkspaceReferencePrompt(workspaceReferences) + : '' + const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n') + const displayContent = text || ( + workspaceReferences.length > 0 + ? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length }) + : '' + ) + const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, + path: attachment.path, data: attachment.data, mimeType: attachment.mimeType, + lineStart: attachment.lineStart, + lineEnd: attachment.lineEnd, + note: attachment.note, + quote: attachment.quote, })) + const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({ + type: 'file' as const, + name: reference.name, + path: reference.absolutePath ?? reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + })) + const visibleAttachmentPayload: AttachmentRef[] = [ + ...uploadAttachmentPayload, + ...workspaceReferences.map((reference) => ({ + type: 'file' as const, + name: reference.name, + path: reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + })), + ] - sendMessage(activeTabId!, text, attachmentPayload) + sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], { + displayContent, + displayAttachments: visibleAttachmentPayload, + }) setInput('') setAttachments([]) + if (!isMemberSession) clearWorkspaceReferences(activeTabId!) setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) + setLocalSlashPanel(null) } const handleKeyDown = (event: React.KeyboardEvent) => { @@ -275,6 +441,14 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { return } + if (localSlashPanel) { + if (event.key === 'Escape') { + event.preventDefault() + setLocalSlashPanel(null) + return + } + } + if (slashMenuOpen && filteredCommands.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() @@ -286,7 +460,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) return } - if (event.key === 'Enter' || event.key === 'Tab') { + if (event.key === 'Enter') { + if (exactSlashCommand && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase()) { + event.preventDefault() + handleSubmit() + return + } + event.preventDefault() + const selected = filteredCommands[slashSelectedIndex] + if (selected) selectSlashCommand(selected.name) + return + } + if (event.key === 'Tab') { event.preventDefault() const selected = filteredCommands[slashSelectedIndex] if (selected) selectSlashCommand(selected.name) @@ -381,6 +566,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) + if (activeTabId) removeWorkspaceReference(activeTabId, id) } const insertSlashCommand = () => { @@ -411,25 +597,68 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') return ( -
-
+
+
event.preventDefault()} onDrop={handleDrop} > {!isMemberSession && fileSearchOpen && ( { + onNavigate={(relativePath) => { + if (atCursorPos < 0) return + const replacement = `@${relativePath}` + const tokenEnd = atCursorPos + 1 + atFilter.length + const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}` + const newCursorPos = atCursorPos + replacement.length + setInput(newValue) + setAtFilter(relativePath) + requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) + }) + }} + onSelect={(path, name) => { if (atCursorPos >= 0) { - // Insert name at cursor position, replacing filter text - const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}` - const newCursorPos = atCursorPos + name.length + const referenceName = name.split('/').filter(Boolean).pop() ?? name + const tokenEnd = atCursorPos + 1 + atFilter.length + const beforeToken = input.slice(0, atCursorPos) + const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '') + const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : '' + const newValue = `${beforeToken}${spacer}${afterToken}` + const newCursorPos = atCursorPos + spacer.length + if (activeTabId) { + addWorkspaceReference(activeTabId, { + kind: 'file', + path, + absolutePath: path, + name: referenceName, + }) + } setInput(newValue) setFileSearchOpen(false) setAtFilter('') @@ -443,6 +672,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { /> )} + {!isMemberSession && localSlashPanel && ( +
+ setLocalSlashPanel(null)} + /> +
+ )} + {!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
)} - {attachments.length > 0 && ( + {composerAttachments.length > 0 && ( isHeroComposer ? ( - + ) : (
- +
) )} @@ -519,14 +760,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { placeholder={composerPlaceholder} disabled={isWorkspaceMissing} rows={1} - className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50" + className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${ + compact ? 'py-1.5 pb-11' : 'py-2 pb-12' + }`} /> )}
-
+ : `absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] ${ + compact ? 'gap-2 px-2.5 py-2' : 'px-3 py-3' + }`}> +
{!isMemberSession && ( <>
@@ -561,18 +806,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { )}
- + )}
-
- {!isMemberSession && } +
+ {!isMemberSession && activeTabId && ( + + )}
@@ -590,16 +847,17 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { {!isMemberSession && ( -
+
{hasMessages ? ( ) : ( { if (!activeTabId) return const oldId = activeTabId @@ -607,6 +865,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const { replaceTabSession } = useTabStore.getState() const { disconnectSession, connectToSession } = useChatStore.getState() const newId = await createSession(newWorkDir) + useSessionRuntimeStore.getState().moveSelection(oldId, newId) disconnectSession(oldId) replaceTabSession(oldId, newId) connectToSession(newId) diff --git a/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx b/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx index c8e372f2..409cef44 100644 --- a/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +++ b/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx @@ -73,12 +73,14 @@ vi.mock('../../api/computerUse', () => ({ })) import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { ComputerUsePermissionModal } from './ComputerUsePermissionModal' describe('ComputerUsePermissionModal', () => { beforeEach(() => { sendMock.mockReset() openSettingsMock.mockReset() + useSettingsStore.setState({ locale: 'en' }) useChatStore.setState({ sessions: {} }) }) diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx new file mode 100644 index 00000000..dd4457cc --- /dev/null +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -0,0 +1,215 @@ +import { useCallback, useMemo, useState } from 'react' +import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' +import { useTranslation } from '../../i18n' +import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface' + +type DiffPreviewState = { + loading: boolean + diff?: string + error?: string +} + +type CurrentTurnChangeCardProps = { + sessionId: string + targetUserMessageId: string + checkpoint: SessionTurnCheckpoint + workDir: string | null + error: string | null + isUndoing: boolean + isLatest: boolean + onUndo: () => void +} + +type ChangedFileEntry = { + apiPath: string + displayPath: string +} + +export function CurrentTurnChangeCard({ + sessionId, + targetUserMessageId, + checkpoint, + workDir, + error, + isUndoing, + isLatest, + onUndo, +}: CurrentTurnChangeCardProps) { + const t = useTranslation() + const [expandedPath, setExpandedPath] = useState(null) + const [diffByPath, setDiffByPath] = useState>({}) + + const files = useMemo( + () => checkpoint.code.filesChanged.map((filePath) => ({ + apiPath: filePath, + displayPath: relativizeWorkspacePath(filePath, workDir), + })), + [checkpoint.code.filesChanged, workDir], + ) + + const toggleDiff = useCallback((fileEntry: ChangedFileEntry) => { + const nextExpandedPath = expandedPath === fileEntry.apiPath ? null : fileEntry.apiPath + setExpandedPath(nextExpandedPath) + if (!nextExpandedPath || diffByPath[fileEntry.apiPath]?.diff || diffByPath[fileEntry.apiPath]?.loading) { + return + } + + setDiffByPath((current) => ({ + ...current, + [fileEntry.apiPath]: { loading: true }, + })) + + void sessionsApi + .getTurnCheckpointDiff( + sessionId, + targetUserMessageId, + fileEntry.apiPath, + checkpoint.target.userMessageIndex, + ) + .then((result) => { + setDiffByPath((current) => ({ + ...current, + [fileEntry.apiPath]: { + loading: false, + diff: result.state === 'ok' ? result.diff || '' : undefined, + error: result.state === 'ok' + ? undefined + : result.error || t('chat.turnChangesDiffUnavailable'), + }, + })) + }) + .catch((diffError) => { + setDiffByPath((current) => ({ + ...current, + [fileEntry.apiPath]: { + loading: false, + error: diffError instanceof Error + ? diffError.message + : String(diffError), + }, + })) + }) + }, [diffByPath, expandedPath, sessionId, t, targetUserMessageId]) + + const cardLabel = isLatest + ? t('chat.turnChangesLatestCardLabel') + : t('chat.turnChangesHistoricalCardLabel') + const subtitle = isLatest + ? t('chat.turnChangesLatestSubtitle') + : t('chat.turnChangesHistoricalSubtitle') + const undoLabel = isLatest + ? t('chat.turnChangesLatestUndo') + : t('chat.turnChangesHistoricalUndo') + const undoAria = isLatest + ? t('chat.turnChangesLatestUndoAria') + : t('chat.turnChangesHistoricalUndoAria') + + return ( +
+
+
+
+ + {t('chat.turnChangesTitle', { count: files.length })} + + + +{checkpoint.code.insertions} + + + -{checkpoint.code.deletions} + +
+
+ {subtitle} +
+
+ + +
+ +
+ {files.map((fileEntry) => { + const isExpanded = expandedPath === fileEntry.apiPath + const diffState = diffByPath[fileEntry.apiPath] + return ( +
+ + + {isExpanded && ( +
+ {diffState?.loading ? ( +
+ {t('chat.turnChangesDiffLoading')} +
+ ) : diffState?.error ? ( +
+ {diffState.error} +
+ ) : diffState?.diff ? ( + + ) : ( +
+ {t('chat.turnChangesDiffUnavailable')} +
+ )} +
+ )} +
+ ) + })} +
+ + {error && ( +
+ {error} +
+ )} +
+ ) +} + +export function relativizeWorkspacePath(filePath: string, workDir: string | null): string { + const normalizedPath = filePath.replace(/\\/g, '/') + const isAbsolute = normalizedPath.startsWith('/') || /^[a-zA-Z]:\//.test(normalizedPath) + if (!workDir || !isAbsolute) return normalizedPath + + const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '') + const comparablePath = normalizedPath.toLowerCase() + const comparableWorkDir = normalizedWorkDir.toLowerCase() + if (comparablePath === comparableWorkDir) return '' + if (comparablePath.startsWith(`${comparableWorkDir}/`)) { + return normalizedPath.slice(normalizedWorkDir.length + 1) + } + return normalizedPath +} diff --git a/desktop/src/components/chat/FileSearchMenu.test.tsx b/desktop/src/components/chat/FileSearchMenu.test.tsx index 45ba72c2..00aed914 100644 --- a/desktop/src/components/chat/FileSearchMenu.test.tsx +++ b/desktop/src/components/chat/FileSearchMenu.test.tsx @@ -1,9 +1,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { FileSearchMenu } from './FileSearchMenu' import { ApiError } from '../../api/client' import { filesystemApi } from '../../api/filesystem' +import { useSettingsStore } from '../../stores/settingsStore' vi.mock('../../api/filesystem', () => ({ filesystemApi: { @@ -15,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({ describe('FileSearchMenu', () => { beforeEach(() => { vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) }) it('shows an explicit error when directory browsing is denied', async () => { @@ -53,4 +55,65 @@ describe('FileSearchMenu', () => { expect(screen.getByText('preview.png')).toBeInTheDocument() }) }) + + it('navigates directories without selecting them as attachments', async () => { + const onSelect = vi.fn() + const onNavigate = vi.fn() + vi.mocked(filesystemApi.search).mockResolvedValueOnce({ + currentPath: '/repo', + parentPath: '/', + query: 'backend', + entries: [ + { name: 'backend', path: '/repo/backend', isDirectory: true }, + ], + }) + vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ + currentPath: '/repo/backend', + parentPath: '/repo', + entries: [ + { name: 'src', path: '/repo/backend/src', isDirectory: true }, + ], + }) + + render( + , + ) + + fireEvent.click(await screen.findByText('backend')) + + expect(onSelect).not.toHaveBeenCalled() + expect(onNavigate).toHaveBeenCalledWith('backend/') + await waitFor(() => { + expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true }) + }) + }) + + it('passes nested relative file paths when selecting a file', async () => { + const onSelect = vi.fn() + vi.mocked(filesystemApi.search).mockResolvedValueOnce({ + currentPath: '/repo/backend/src', + parentPath: '/repo/backend', + query: 'pictactic', + entries: [ + { name: 'pictactic', path: '/repo/backend/src/pictactic', isDirectory: false }, + ], + }) + + render( + , + ) + + fireEvent.click(await screen.findByText('pictactic')) + + expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic') + }) }) diff --git a/desktop/src/components/chat/FileSearchMenu.tsx b/desktop/src/components/chat/FileSearchMenu.tsx index f89c1531..89ed23c7 100644 --- a/desktop/src/components/chat/FileSearchMenu.tsx +++ b/desktop/src/components/chat/FileSearchMenu.tsx @@ -18,9 +18,14 @@ type Props = { cwd: string filter?: string onSelect: (path: string, relativePath: string) => void + onNavigate?: (relativePath: string) => void } -export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect }, ref) => { +function joinRelativePath(base: string, name: string) { + return [base.replace(/\/+$/, ''), name].filter(Boolean).join('/') +} + +export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect, onNavigate }, ref) => { const t = useTranslation() const [entries, setEntries] = useState([]) const [errorMessage, setErrorMessage] = useState(null) @@ -118,13 +123,19 @@ export const FileSearchMenu = forwardRef(({ cwd, fi } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() - if (entries[selectedIndex]) { - onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name) + const selected = entries[selectedIndex] + if (selected) { + if (selected.isDirectory) { + void loadDir(selected.path, '') + onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)}/`) + } else { + onSelect(selected.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)) + } } return } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries, selectedIndex]) + }, [entries, selectedIndex, filter, loadDir, onNavigate, onSelect]) useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) @@ -185,7 +196,8 @@ export const FileSearchMenu = forwardRef(({ cwd, fi key={entry.path} data-index={i} onClick={() => { - void loadDir(entry.path, filter) + void loadDir(entry.path, '') + onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name)}/`) }} onMouseEnter={() => setSelectedIndex(i)} className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${ @@ -204,7 +216,7 @@ export const FileSearchMenu = forwardRef(({ cwd, fi +
+
{children}
+
+ ) +} + +function LoadingState({ label }: { label: string }) { + return ( +
+
+ {label} +
+ ) +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+
{title}
+
{body}
+
+ ) +} + +function ErrorState({ message }: { message: string }) { + return ( +
+ {message} +
+ ) +} + +function formatNumber(value: number | undefined) { + return new Intl.NumberFormat().format(value ?? 0) +} + +function formatDuration(seconds: number | undefined) { + const total = Math.max(0, Math.round(seconds ?? 0)) + if (total < 60) return `${total}s` + const minutes = Math.floor(total / 60) + const remaining = total % 60 + return remaining ? `${minutes}m ${remaining}s` : `${minutes}m` +} + +function formatPercent(value: number | undefined) { + const percent = Math.max(0, Math.min(100, value ?? 0)) + return `${percent.toFixed(percent >= 10 || Number.isInteger(percent) ? 0 : 1)}%` +} + +function sessionInspectorInitialTab(command: LocalSlashCommandName): SessionInspectorTab { + if (command === 'cost') return 'usage' + if (command === 'context') return 'context' + return 'status' +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object') +} + +function isSessionInspectionResponse(value: unknown): value is SessionInspectionResponse { + if (!isRecord(value)) return false + if (typeof value.active !== 'boolean') return false + if (!isRecord(value.status)) return false + return ( + typeof value.status.sessionId === 'string' && + typeof value.status.workDir === 'string' && + typeof value.status.permissionMode === 'string' + ) +} + +function assertSessionInspectionResponse(value: unknown, t: Translate): SessionInspectionResponse { + if (isSessionInspectionResponse(value)) return value + throw new Error(t('slash.inspector.error.unavailable')) +} + +function InspectorSectionTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
{children}
+ {action} +
+ ) +} + +function MetricCard({ label, value, detail }: { label: string; value: React.ReactNode; detail?: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+ {detail &&
{detail}
} +
+ ) +} + +function InspectorNotice({ children }: { children: React.ReactNode }) { + return ( +
+ info + {children} +
+ ) +} + +function KeyValueRows({ rows }: { rows: Array<[string, React.ReactNode]> }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+ ) +} + +function UsageTab({ + usage, + context, + error, + t, +}: { + usage?: SessionUsageSnapshot + context?: SessionContextSnapshot + error?: string + t: Translate +}) { + if (error && !usage) return + if (!usage) { + return + } + + const usageHasTokens = ( + usage.totalInputTokens + + usage.totalOutputTokens + + usage.totalCacheReadInputTokens + + usage.totalCacheCreationInputTokens + ) > 0 + const apiUsage = context?.apiUsage + const useContextUsageFallback = !usageHasTokens && !!apiUsage + const totalInputTokens = useContextUsageFallback ? apiUsage.input_tokens : usage.totalInputTokens + const totalOutputTokens = useContextUsageFallback ? apiUsage.output_tokens : usage.totalOutputTokens + const totalCacheReadInputTokens = useContextUsageFallback ? apiUsage.cache_read_input_tokens : usage.totalCacheReadInputTokens + const totalCacheCreationInputTokens = useContextUsageFallback ? apiUsage.cache_creation_input_tokens : usage.totalCacheCreationInputTokens + const models = Array.isArray(usage.models) && usage.models.length > 0 + ? usage.models + : useContextUsageFallback + ? [{ + model: context?.model ?? 'current-model', + displayName: context?.model ?? t('slash.inspector.status.activeModel'), + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadInputTokens: totalCacheReadInputTokens, + cacheCreationInputTokens: totalCacheCreationInputTokens, + webSearchRequests: 0, + costUSD: 0, + costDisplay: 'n/a', + contextWindow: context?.rawMaxTokens ?? 0, + maxOutputTokens: 0, + }] + : [] + const sourceLabel = useContextUsageFallback + ? t('slash.inspector.usage.source.contextSnapshot') + : usage.source === 'transcript' + ? t('slash.inspector.usage.source.transcript') + : t('slash.inspector.usage.source.currentProcess') + + return ( +
+ {useContextUsageFallback && ( + + {t('slash.inspector.usage.contextSnapshotNotice')} + + )} + {usage.source === 'transcript' && ( +
+ {t('slash.inspector.usage.transcriptNotice')} +
+ )} + {usage.hasUnknownModelCost && ( +
+ {t('slash.inspector.usage.unknownCost')} +
+ )} +
+ + + + + + + + + +
+
+
{t('slash.inspector.usage.byModel')}
+ {models.length === 0 ? ( + + ) : ( +
+ {models.map((model) => ( +
+
+
{model.displayName || model.model}
+
{t('slash.inspector.usage.tokens')}
+
+
+
{t('slash.inspector.usage.input')}
+
+
+
+
{formatNumber(model.inputTokens)}
+
+
+
{t('slash.inspector.usage.output')}
+
+
+
+
{formatNumber(model.outputTokens)}
+
+
+ ))} +
+ )} +
+
+ ) +} + +type ContextCategory = SessionContextSnapshot['categories'][number] + +function isCapacityCategory(category: ContextCategory) { + const name = category.name.toLowerCase() + return category.isDeferred || name.includes('free') || name.includes('autocompact') +} + +function ContextStackedBar({ categories, rawMaxTokens }: { categories: ContextCategory[]; rawMaxTokens: number }) { + const activeCategories = categories.filter((category) => !isCapacityCategory(category) && category.tokens > 0) + if (activeCategories.length === 0) return null + + return ( +
+
+ {activeCategories.map((category) => ( +
+ ))} +
+
+ ) +} + +function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: ContextCategory[]; rawMaxTokens: number; t: Translate }) { + const visibleCategories = categories.filter((category) => category.tokens > 0) + if (visibleCategories.length === 0) { + return + } + + return ( +
+ {t('slash.inspector.context.categoryTitle')} +
+ {visibleCategories.map((category) => { + const percent = rawMaxTokens > 0 ? (category.tokens / rawMaxTokens) * 100 : 0 + const muted = isCapacityCategory(category) + return ( +
+
+
+ + {category.name} + +
+
+
{formatNumber(category.tokens)}
+
{formatPercent(percent)}
+
+
+
+
+
+
+ ) + })} +
+
+ ) +} + +function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) { + return ( +
+
{label}
+
{value}
+ {detail &&
{detail}
} +
+ ) +} + +function statusDisplayLabel(status: string, t: Translate) { + const normalized = status.toLowerCase() + if (normalized === 'connected') return t('slash.inspector.status.connected') + if (normalized === 'failed') return t('slash.inspector.status.failed') + return status +} + +function InspectorStatusBadge({ status, t }: { status: string; t: Translate }) { + const normalized = status.toLowerCase() + const isConnected = normalized === 'connected' + const isFailed = normalized === 'failed' + const badgeClass = isConnected + ? 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)]' + : isFailed + ? 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)]' + : 'bg-[var(--color-inspector-chip)] text-[var(--color-inspector-muted-strong)]' + const dotClass = isConnected ? 'bg-[var(--color-inspector-success)]' : isFailed ? 'bg-[var(--color-inspector-danger)]' : 'bg-[var(--color-inspector-muted)]' + + return ( + + + {statusDisplayLabel(status, t)} + + ) +} + +function McpServerIcon({ status }: { status: string }) { + const isFailed = status === 'failed' + const icon = isFailed ? 'power_off' : 'dns' + return ( + + {icon} + + ) +} + +function ContextOverview({ context, categories, t }: { context: SessionContextSnapshot; categories: ContextCategory[]; t: Translate }) { + const usedPercent = Math.min(100, Math.max(0, context.percentage)) + const freeTokens = Math.max(0, context.rawMaxTokens - context.totalTokens) + const freePercent = context.rawMaxTokens > 0 ? (freeTokens / context.rawMaxTokens) * 100 : 0 + return ( +
+
+ {t('slash.inspector.context.windowUsage')} + {context.model} +
+
+ {formatNumber(context.totalTokens)} + / + {formatNumber(context.rawMaxTokens)} + [{formatPercent(usedPercent)} {t('slash.inspector.context.used')}] +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +function ContextTab({ + context, + error, + loading, + t, +}: { + context?: SessionContextSnapshot + error?: string + loading?: boolean + t: Translate +}) { + if (error && !context) return + if (loading && !context) return + if (!context) { + return + } + + const categories = Array.isArray(context.categories) ? context.categories : [] + return ( +
+ + +
+ ) +} + +function StatusTab({ + data, + commands, + t, +}: { + data: SessionInspectionResponse + commands?: SlashCommandOption[] + t: Translate +}) { + const mcpServers = Array.isArray(data.status.mcpServers) ? data.status.mcpServers : [] + const tools = Array.isArray(data.status.tools) ? data.status.tools : [] + const model = data.status.model ?? data.context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown') + const slashCommandCount = (data.status.slashCommandCount ?? 0) > 0 + ? data.status.slashCommandCount + : commands?.length ?? 0 + const connectedMcp = mcpServers.filter((server) => server.status === 'connected').length + const failedMcp = mcpServers.filter((server) => server.status === 'failed').length + return ( +
+
+ + + {data.active ? t('slash.inspector.status.running') : t('slash.inspector.status.notRunning')} + + )} + /> + + + {formatNumber(connectedMcp)} + / + {formatNumber(failedMcp)} + + )} + detail={( + + {t('slash.inspector.status.connected')} + + {t('slash.inspector.status.failed')} + + )} + /> + +
+
+ {t('slash.inspector.status.sessionMetadata')} + {data.status.sessionId}], + [t('slash.inspector.status.workingDirectory'), {data.status.cwd ?? data.status.workDir}], + [t('slash.inspector.status.permissionMode'), {data.status.permissionMode}], + [t('slash.inspector.status.authToken'), data.status.apiKeySource ?? t('slash.inspector.status.unknown')], + [t('slash.inspector.status.outputStyle'), data.status.outputStyle ?? t('slash.inspector.status.default')], + ]} + /> +
+ {mcpServers.length > 0 && ( +
+ ↻ {t('slash.inspector.status.refresh')}} + > + {t('slash.inspector.status.mcpServers')} + +
+ {mcpServers.map((server) => ( +
+
+ + {server.name} +
+ +
+ ))} +
+
+ )} +
+ ) +} + +function SessionInspectorShell({ + selectedTab, + tabs, + onSelectTab, + onClose, + children, + t, +}: { + selectedTab: SessionInspectorTab + tabs: Array<{ id: SessionInspectorTab; label: string }> + onSelectTab: (tab: SessionInspectorTab) => void + onClose: () => void + children: React.ReactNode + t: Translate +}) { + return ( +
+
+
{t('slash.inspector.title')}
+
+ {tabs.map((tab) => ( + + ))} +
+
+ +
+
+
{children}
+
+ ) +} + +function SessionInspectorPanel({ + command, + sessionId, + commands, + onClose, +}: { + command: LocalSlashCommandName + sessionId?: string + commands?: SlashCommandOption[] + onClose: () => void +}) { + const t = useTranslation() + const [selectedTab, setSelectedTab] = useState(() => sessionInspectorInitialTab(command)) + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [contextLoading, setContextLoading] = useState(false) + const [contextError, setContextError] = useState(null) + const contextRequestSessionRef = useRef(null) + + useEffect(() => { + if (command !== 'status' && command !== 'cost' && command !== 'context') return + setSelectedTab(sessionInspectorInitialTab(command)) + }, [command]) + + useEffect(() => { + if (!sessionId) { + setError(t('slash.inspector.error.noActiveSession')) + return + } + let cancelled = false + setData(null) + setError(null) + setContextLoading(false) + setContextError(null) + contextRequestSessionRef.current = null + sessionsApi.getInspection(sessionId, { includeContext: false }) + .then((response) => { + if (!cancelled) setData(assertSessionInspectionResponse(response, t)) + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [sessionId, t]) + + useEffect(() => { + if (!sessionId || selectedTab !== 'context' || data === null || data.context) return + if (contextRequestSessionRef.current === sessionId) return + contextRequestSessionRef.current = sessionId + let cancelled = false + setContextLoading(true) + setContextError(null) + sessionsApi.getInspection(sessionId, { includeContext: true, timeout: 45_000 }) + .then((response) => { + if (cancelled) return + const inspected = assertSessionInspectionResponse(response, t) + setData((current) => current + ? { + ...current, + context: inspected.context, + errors: { + ...(current.errors ?? {}), + ...(inspected.errors ?? {}), + }, + } + : inspected) + setContextError(inspected.errors?.context ?? null) + }) + .catch((err) => { + if (!cancelled) setContextError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setContextLoading(false) + }) + return () => { + cancelled = true + } + }, [data, selectedTab, sessionId, t]) + + const tabs: Array<{ id: SessionInspectorTab; label: string }> = [ + { id: 'status', label: t('slash.inspector.tab.status') }, + { id: 'usage', label: t('slash.inspector.tab.usage') }, + { id: 'context', label: t('slash.inspector.tab.context') }, + ] + + return ( + + {error ? ( + + ) : data === null ? ( + + ) : selectedTab === 'usage' ? ( + + ) : selectedTab === 'context' ? ( + + ) : ( + + )} + + ) +} + +function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { + const t = useTranslation() + const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) + const selectServer = useMcpStore((s) => s.selectServer) + const [servers, setServers] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + mcpApi.list(cwd) + .then(async (response) => { + if (cancelled) return + const visibleServers = response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project') + setServers(visibleServers) + + const statusResults = await Promise.allSettled( + visibleServers.map((server) => mcpApi.status(server.name, cwd)), + ) + if (cancelled) return + + const liveServers = new Map() + for (const result of statusResults) { + if (result.status === 'fulfilled') { + liveServers.set(result.value.server.name, result.value.server) + } + } + if (liveServers.size > 0) { + setServers((current) => + current?.map((server) => liveServers.get(server.name) ?? server) ?? current, + ) + } + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [cwd]) + + const grouped = useMemo(() => { + const groups = new Map() + for (const server of servers ?? []) { + const key = server.scope + const existing = groups.get(key) ?? [] + existing.push(server) + groups.set(key, existing) + } + return groups + }, [servers]) + + return ( + + {error ? ( + + ) : servers === null ? ( + + ) : servers.length === 0 ? ( + + ) : ( +
+ {['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => ( +
+
+
{scopeLabel(scope, t)}
+
{grouped.get(scope)?.length ?? 0}
+
+
+ {grouped.get(scope)?.map((server) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) +} + +function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { + const t = useTranslation() + const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) + const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail) + const [skills, setSkills] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + skillsApi.list(cwd) + .then((response) => { + if (cancelled) return + setSkills(response.skills.filter((skill) => skill.userInvocable)) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [cwd]) + + return ( + + {error ? ( + + ) : skills === null ? ( + + ) : skills.length === 0 ? ( + + ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ ) +} + +const COMMAND_GROUPS = [ + { + titleKey: 'slash.help.group.context', + names: ['clear', 'compact', 'context', 'cost'], + }, + { + titleKey: 'slash.help.group.project', + names: ['init', 'review', 'commit', 'pr'], + }, + { + titleKey: 'slash.help.group.desktop', + names: ['mcp', 'skills', 'plugin', 'help'], + }, +] satisfies Array<{ titleKey: TranslationKey; names: string[] }> + +function HelpPanel({ + commands, + onClose, +}: { + commands?: SlashCommandOption[] + onClose: () => void +}) { + const t = useTranslation() + const commandMap = useMemo(() => { + const map = new Map() + for (const command of commands ?? []) { + map.set(command.name, command) + } + return map + }, [commands]) + + const groupedNames = new Set(COMMAND_GROUPS.flatMap((group) => group.names)) + const otherCommands = (commands ?? []) + .filter((command) => !groupedNames.has(command.name)) + .slice(0, 12) + const hiddenOtherCommandCount = Math.max( + 0, + (commands ?? []).filter((command) => !groupedNames.has(command.name)).length - otherCommands.length, + ) + + const renderCommand = (command: SlashCommandOption) => ( +
+
/{command.name}
+
{command.description}
+
+ ) + + return ( + +
+ {COMMAND_GROUPS.map((group) => { + const entries = group.names + .map((name) => commandMap.get(name)) + .filter((command): command is SlashCommandOption => Boolean(command)) + if (entries.length === 0) return null + return ( +
+
{t(group.titleKey)}
+
+ {entries.map(renderCommand)} +
+
+ ) + })} + + {otherCommands.length > 0 && ( +
+
{t('slash.help.group.more')}
+
+ {otherCommands.map(renderCommand)} +
+ {hiddenOtherCommandCount > 0 && ( +

+ {t('slash.help.moreAvailable', { count: hiddenOtherCommandCount })} +

+ )} +
+ )} +
+
+ ) +} + +export function LocalSlashCommandPanel({ command, sessionId, cwd, commands, onClose }: Props) { + if (command === 'mcp') return + if (command === 'skills') return + if (command === 'status' || command === 'cost' || command === 'context') { + return + } + return +} diff --git a/desktop/src/components/chat/MermaidRenderer.test.tsx b/desktop/src/components/chat/MermaidRenderer.test.tsx index 120337a1..3775e44a 100644 --- a/desktop/src/components/chat/MermaidRenderer.test.tsx +++ b/desktop/src/components/chat/MermaidRenderer.test.tsx @@ -30,6 +30,9 @@ describe('MermaidRenderer', () => { const previewButton = await screen.findByRole('button', { name: /preview/i }) expect(previewButton).toBeInTheDocument() + expect(initializeMock).toHaveBeenCalledWith(expect.objectContaining({ + suppressErrorRendering: true, + })) fireEvent.click(previewButton) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index 22978b95..ab5b87da 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -32,6 +32,7 @@ function initMermaid() { startOnLoad: false, theme: 'default', securityLevel: 'strict', + suppressErrorRendering: true, fontFamily: 'var(--font-sans)', }) mermaidInitialized = true diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 1943ddb5..3861bd40 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -3,25 +3,35 @@ import { CopyButton } from '../shared/CopyButton' type Props = { copyText?: string copyLabel: string + align?: 'start' | 'end' } export function MessageActionBar({ copyText, copyLabel, + align = 'start', }: Props) { const hasCopy = Boolean(copyText?.trim()) if (!hasCopy) return null return ( -
- +
+
+ +
) } diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 74bcfe25..b1e75d32 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MessageList, buildRenderModel } from './MessageList' +import { relativizeWorkspacePath } from './CurrentTurnChangeCard' +import { sessionsApi } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' import type { UIMessage } from '../../types/chat' import type { PerSessionState } from '../../stores/chatStore' @@ -26,14 +29,28 @@ function makeSessionState(overrides: Partial = {}): PerSessionS slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, + composerPrefill: null, ...overrides, } } describe('MessageList nested tool calls', () => { beforeEach(() => { + vi.restoreAllMocks() + useSettingsStore.setState({ locale: 'en' }) useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } }) + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation( + () => new Promise(() => {}), + ) + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) }) it('renders sub-agent tool calls inline beneath the parent agent tool call', () => { @@ -79,6 +96,41 @@ describe('MessageList nested tool calls', () => { expect(container.textContent).toContain('Agent') }) + it('does not render blank assistant bubbles for whitespace-only text', () => { + const messages: UIMessage[] = [ + { + id: 'assistant-empty', + type: 'assistant_text', + content: '\n\n ', + timestamp: 1, + }, + { + id: 'tool-bash', + type: 'tool_use', + toolName: 'Bash', + toolUseId: 'bash-1', + input: { command: 'pwd' }, + timestamp: 2, + }, + ] + + const { renderItems } = buildRenderModel(messages) + expect(renderItems).toHaveLength(1) + expect(renderItems[0]).toMatchObject({ kind: 'tool_group' }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages, + streamingText: '\n ', + }), + }, + }) + + const { container } = render() + expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0) + }) + it('keeps root tool runs split when nested child tool calls appear between them', () => { const messages: UIMessage[] = [ { @@ -124,7 +176,7 @@ describe('MessageList nested tool calls', () => { expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1']) }) - it('keeps later nested tool calls after an interleaved user message', () => { + it('keeps later nested tool calls under their parent after an interleaved user message', () => { const messages: UIMessage[] = [ { id: 'tool-agent', @@ -170,11 +222,37 @@ describe('MessageList nested tool calls', () => { expect(renderedKinds).toEqual([ 'tool:agent-1', 'message:user-follow-up', - 'tool:write-1', ]) expect( (childToolCallsByParent.get('agent-1') ?? []).map((toolCall) => toolCall.toolUseId), - ).toEqual(['read-1']) + ).toEqual(['read-1', 'write-1']) + }) + + it('does not render parented orphan tool results as root session messages', () => { + const messages: UIMessage[] = [ + { + id: 'tool-agent', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'agent-1', + input: { description: 'Inspect src/components' }, + timestamp: 1, + }, + { + id: 'result-child', + type: 'tool_result', + toolUseId: 'grep-1', + content: 'Found 22 files', + isError: false, + timestamp: 2, + parentToolUseId: 'agent-1', + }, + ] + + const { renderItems } = buildRenderModel(messages) + + expect(renderItems).toHaveLength(1) + expect(renderItems[0]).toMatchObject({ kind: 'tool_group' }) }) it('shows failed agent status and compact unavailable summary for Explore launch errors', () => { @@ -230,7 +308,13 @@ describe('MessageList nested tool calls', () => { toolUseId: 'agent-1', content: { status: 'completed', - content: [{ type: 'text', text: longResult }], + content: [ + { type: 'text', text: longResult }, + { + type: 'text', + text: "agentId: a0c0c732f61442dc1 (use SendMessage with to: 'a0c0c732f61442dc1' to continue this agent)\ntotal_tokens: 17195\ntool_uses: 2\nduration_ms: 41368", + }, + ], }, isError: false, timestamp: 2, @@ -249,6 +333,8 @@ describe('MessageList nested tool calls', () => { const dialog = screen.getByRole('dialog') expect(within(dialog).getByText(/第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。/)).toBeTruthy() + expect(within(dialog).queryByText(/agentId:/)).toBeNull() + expect(within(dialog).queryByText(/total_tokens/)).toBeNull() expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy() }) @@ -335,6 +421,911 @@ describe('MessageList nested tool calls', () => { ) }) + it('does not force-scroll to the bottom while the user is reading history', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'streaming', + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '历史消息', + timestamp: 1, + }, + ], + streamingText: 'streaming', + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + let scrollTop = 120 + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = value + }, + }) + + scrollIntoView.mockClear() + fireEvent.scroll(scroller) + + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [ACTIVE_TAB]: { + ...state.sessions[ACTIVE_TAB]!, + streamingText: 'streaming new token', + }, + }, + })) + }) + + await waitFor(() => { + expect(screen.getByText('streaming new token')).toBeTruthy() + }) + expect(scrollIntoView).not.toHaveBeenCalled() + }) + + it('keeps auto-scrolling when new output arrives while already near the bottom', async () => { + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'streaming', + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '最新消息', + timestamp: 1, + }, + ], + streamingText: 'streaming', + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + let scrollTop = 552 + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = value + }, + }) + + scrollIntoView.mockClear() + fireEvent.scroll(scroller) + + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [ACTIVE_TAB]: { + ...state.sessions[ACTIVE_TAB]!, + streamingText: 'streaming next token', + }, + }, + })) + }) + + await waitFor(() => { + expect(screen.getByText('streaming next token')).toBeTruthy() + }) + expect(scrollIntoView).toHaveBeenCalled() + }) + + it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '请把这条 prompt 放在右侧', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: '这条回复应该停在左侧。', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + const userShell = screen.getByText('请把这条 prompt 放在右侧').closest('[data-message-shell="user"]') + const assistantShell = screen.getByText('这条回复应该停在左侧。').closest('[data-message-shell="assistant"]') + const userActions = screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]') + const assistantActions = screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]') + + expect(userShell).toBeTruthy() + expect(userShell?.className).toContain('items-end') + expect(assistantShell).toBeTruthy() + expect(assistantShell?.className).toContain('items-start') + expect(assistantShell?.className).not.toContain('ml-10') + expect(userActions?.getAttribute('data-align')).toBe('end') + expect(assistantActions?.getAttribute('data-align')).toBe('start') + }) + + it('uses the document column for markdown-heavy assistant replies', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'assistant-doc', + type: 'assistant_text', + content: [ + '## 交付结果', + '', + '已完成以下内容:', + '', + '- 添加任务', + '- 删除任务', + '', + '```bash', + 'npm run build', + '```', + ].join('\n'), + timestamp: 1, + }, + ], + }), + }, + }) + + render() + + const assistantShell = screen.getByText('交付结果').closest('[data-message-shell="assistant"]') + expect(assistantShell?.getAttribute('data-layout')).toBe('document') + expect(assistantShell?.className).toContain('w-full') + expect(assistantShell?.className).not.toContain('ml-10') + }) + + it('does not expose the old message-level rewind action', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 4, + deletions: 1, + }, + }, + ], + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '做一个页面', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByRole('button', { name: 'Undo current turn changes' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull() + }) + + it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '继续优化 workflow.py', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: '两个文件均已优化完成,功能保持不变。', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('两个文件均已优化完成,功能保持不变。')).toBeTruthy() + await waitFor(() => { + expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalled() + }) + expect(screen.queryByText(/Cannot read properties/)).toBeNull() + expect(screen.queryByLabelText('Turn changed files')).toBeNull() + }) + + it('renders multiple historical turn change cards across three turns', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 3, + }, + code: { + available: true, + filesChanged: ['src/first.ts'], + insertions: 3, + deletions: 1, + }, + }, + { + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 3, + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 5, + deletions: 2, + }, + }, + { + target: { + targetUserMessageId: 'user-3', + userMessageIndex: 2, + userMessageCount: 3, + }, + code: { + available: true, + filesChanged: [], + insertions: 0, + deletions: 0, + }, + }, + ], + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一段', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'ok', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二段', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'done', + timestamp: 4, + }, + { + id: 'user-3', + type: 'user_text', + content: '第三段', + timestamp: 5, + }, + { + id: 'assistant-3', + type: 'assistant_text', + content: 'done', + timestamp: 6, + }, + ], + }), + }, + }) + + render() + + const cards = await screen.findAllByLabelText('Turn changed files') + expect(cards).toHaveLength(2) + expect(screen.getByText('src/first.ts')).toBeTruthy() + expect(screen.getByText('src/second.ts')).toBeTruthy() + expect(screen.queryByText('src/third.ts')).toBeNull() + }) + + it('expands a historical turn diff through the turn checkpoint diff API', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 2, + }, + code: { + available: true, + filesChanged: ['src/first.ts'], + insertions: 1, + deletions: 1, + }, + }, + { + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 2, + deletions: 0, + }, + }, + ], + }) + vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({ + state: 'ok', + path: 'src/first.ts', + diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new', + }) + vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({ + state: 'ok', + path: 'src/first.ts', + diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new', + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' })) + + const diffSurface = await screen.findByTestId('workspace-code') + expect(diffSurface.textContent).toContain('+new') + expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith( + ACTIVE_TAB, + 'user-1', + 'src/first.ts', + 0, + ) + expect(sessionsApi.getWorkspaceDiff).not.toHaveBeenCalled() + }) + + it('keeps checkpoint paths bound to the original turn cwd when expanding historical diffs', async () => { + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/current-project', + repoName: 'current-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 2, + }, + workDir: '/tmp/old-project', + code: { + available: true, + filesChanged: ['/tmp/old-project/src/first.ts'], + insertions: 1, + deletions: 1, + }, + }, + ], + }) + vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({ + state: 'ok', + path: '/tmp/old-project/src/first.ts', + diff: 'diff --git a/src/first.ts b/src/first.ts\n-old\n+new', + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' })) + + await screen.findByTestId('workspace-code') + expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith( + ACTIVE_TAB, + 'user-1', + '/tmp/old-project/src/first.ts', + 0, + ) + }) + + it('relativizes Windows checkpoint paths against the turn workdir', () => { + expect(relativizeWorkspacePath( + 'C:\\Users\\Relakkes\\aacc\\src\\App.tsx', + 'c:/users/relakkes/aacc', + )).toBe('src/App.tsx') + }) + + it('matches live turn change checkpoints by user message index when transcript ids differ from local UI ids', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'transcript-user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + code: { + available: true, + filesChanged: ['src/live.ts'], + insertions: 7, + deletions: 0, + }, + }, + ], + }) + vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({ + state: 'ok', + path: 'src/live.ts', + diff: 'diff --session a/src/live.ts b/src/live.ts\n+live', + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'local-user-temp-id', + type: 'user_text', + content: '实时这一轮', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('src/live.ts')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show diff for src/live.ts' })) + await screen.findByTestId('workspace-code') + expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith( + ACTIVE_TAB, + 'transcript-user-1', + 'src/live.ts', + 0, + ) + }) + + it('keeps turn change cards anchored when the only response item is filtered from rendering', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + code: { + available: true, + filesChanged: ['src/blank-response.ts'], + insertions: 3, + deletions: 0, + }, + }, + ], + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '生成文件', + timestamp: 1, + }, + { + id: 'assistant-empty', + type: 'assistant_text', + content: '\n ', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('src/blank-response.ts')).toBeTruthy() + }) + + it('keeps historical turn change cards visible while the next turn is running', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + code: { + available: true, + filesChanged: ['src/first.ts'], + insertions: 1, + deletions: 0, + }, + }, + ], + }) + + const messages: UIMessage[] = [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ] + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ messages }), + }, + }) + + render() + + expect(await screen.findByText('src/first.ts')).toBeTruthy() + + act(() => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages, + chatState: 'thinking', + }), + }, + }) + }) + + await waitFor(() => { + expect(screen.getByText('src/first.ts')).toBeTruthy() + }) + }) + + it('confirms before rewinding to an earlier turn from a historical change card', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 2, + }, + code: { + available: true, + filesChanged: ['src/first.ts'], + insertions: 1, + deletions: 0, + }, + }, + { + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 1, + deletions: 0, + }, + }, + ], + }) + vi.spyOn(sessionsApi, 'rewind') + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 1, + deletions: 0, + }, + }) + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + removedMessageIds: ['user-1', 'assistant-1'], + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 1, + deletions: 0, + }, + }) + const reloadHistory = vi.fn().mockResolvedValue(undefined) + const queueComposerPrefill = vi.fn() + + useChatStore.setState({ + reloadHistory, + queueComposerPrefill, + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '做一个页面', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'first done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮需求', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + const historicalCard = (await screen.findByText('src/first.ts')).closest('section') + expect(historicalCard).toBeTruthy() + fireEvent.click( + within(historicalCard as HTMLElement).getByRole('button', { + name: 'Rewind to before this turn', + }), + ) + + expect(sessionsApi.rewind).not.toHaveBeenCalled() + const dialog = await screen.findByRole('dialog', { name: 'Rewind to before this turn?' }) + expect( + within(dialog).getByText( + 'This will rewind the conversation to before this turn and restore tracked files for that checkpoint.', + ), + ).toBeTruthy() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Rewind to before this turn' })) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + expectedContent: '做一个页面', + }) + }) + expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB) + expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { + text: '做一个页面', + attachments: undefined, + }) + }) + + it('does not render cards for turns without file changes', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 2, + }, + code: { + available: true, + filesChanged: ['src/first.ts'], + insertions: 2, + deletions: 1, + }, + }, + { + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + code: { + available: true, + filesChanged: [], + insertions: 0, + deletions: 0, + }, + }, + ], + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮改文件', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'first done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮只解释', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + const cards = await screen.findAllByLabelText('Turn changed files') + expect(cards).toHaveLength(1) + expect(screen.getByText('src/first.ts')).toBeTruthy() + expect(screen.queryByText('src/second.ts')).toBeNull() + }) + it('shows raw startup details under translated CLI startup errors', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 8c1876e2..ce9f1f1a 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,6 +1,10 @@ -import { useRef, useEffect, useMemo, memo } from 'react' +import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react' +import { ApiError } from '../../api/client' +import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' +import { useTeamStore } from '../../stores/teamStore' +import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' import { UserMessage } from './UserMessage' @@ -13,7 +17,9 @@ import { PermissionDialog } from './PermissionDialog' import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' +import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { AgentTaskNotification, UIMessage } from '../../types/chat' +import { ConfirmDialog } from '../shared/ConfirmDialog' type ToolCall = Extract type ToolResult = Extract @@ -28,6 +34,21 @@ type RenderModel = { childToolCallsByParent: Map } +type RewindTurnTarget = { + messageId: string + userMessageIndex: number + content: string + expectedContent: string + attachments?: Extract['attachments'] +} + +type TurnChangeCardModel = { + target: RewindTurnTarget + checkpoint: SessionTurnCheckpoint + workDir: string | null + isLatest: boolean +} + function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -47,24 +68,16 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { const childToolCallsByParent = new Map() const toolUseIds = new Set() let pendingToolCalls: ToolCall[] = [] - const inlineParentToolUseIds = new Set() - const flushGroup = (resetInlineParents = false) => { + const flushGroup = () => { if (pendingToolCalls.length > 0) { items.push({ kind: 'tool_group', toolCalls: [...pendingToolCalls], id: `group-${pendingToolCalls[0]!.id}`, }) - for (const toolCall of pendingToolCalls) { - inlineParentToolUseIds.add(toolCall.toolUseId) - } pendingToolCalls = [] } - - if (resetInlineParents) { - inlineParentToolUseIds.clear() - } } for (const msg of messages) { @@ -77,29 +90,31 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { } for (const msg of messages) { + if (msg.type === 'assistant_text' && !msg.content.trim()) { + continue + } + if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) { continue } + if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { + continue + } if (msg.type === 'tool_use') { - const parentIsPending = msg.parentToolUseId - ? pendingToolCalls.some((toolCall) => toolCall.toolUseId === msg.parentToolUseId) - : false - - if (msg.parentToolUseId && (inlineParentToolUseIds.has(msg.parentToolUseId) || parentIsPending)) { + if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { flushGroup() appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg) - inlineParentToolUseIds.add(msg.toolUseId) continue } if (msg.toolName === 'AskUserQuestion') { - flushGroup(true) + flushGroup() items.push({ kind: 'message', message: msg }) } else { pendingToolCalls.push(msg) } } else { - flushGroup(true) + flushGroup() items.push({ kind: 'message', message: msg }) } } @@ -108,65 +123,387 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { return { renderItems: items, toolResultMap, childToolCallsByParent } } -export function MessageList() { +function isTurnResponseMessage(message: UIMessage) { + return ( + message.type === 'assistant_text' || + message.type === 'tool_use' || + message.type === 'tool_result' || + message.type === 'error' || + message.type === 'task_summary' + ) +} + +export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] { + let userMessageIndex = -1 + const completedTurns: RewindTurnTarget[] = [] + let currentTarget: RewindTurnTarget | null = null + let hasResponseForCurrentTarget = false + + for (const message of messages) { + if (message.type === 'user_text' && !message.pending) { + if (currentTarget && hasResponseForCurrentTarget) { + completedTurns.push(currentTarget) + } + userMessageIndex += 1 + currentTarget = { + messageId: message.id, + userMessageIndex, + content: message.content, + expectedContent: message.modelContent ?? message.content, + attachments: message.attachments, + } + hasResponseForCurrentTarget = false + continue + } + + if (currentTarget && isTurnResponseMessage(message)) { + hasResponseForCurrentTarget = true + } + } + + if (currentTarget && hasResponseForCurrentTarget) { + completedTurns.push(currentTarget) + } + + return completedTurns +} + +export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null { + const completedTurns = getCompletedTurnTargets(messages) + return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null +} + +function buildTurnCardInsertionMap( + renderItems: RenderItem[], + turnChangeCards: TurnChangeCardModel[], +) { + const lastResponseIndexByTurnId = new Map() + const userIndexByTurnId = new Map() + let activeTurnId: string | null = null + + renderItems.forEach((item, index) => { + if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) { + activeTurnId = item.message.id + userIndexByTurnId.set(activeTurnId, index) + return + } + + if (activeTurnId) { + lastResponseIndexByTurnId.set(activeTurnId, index) + } + }) + + const cardsByRenderIndex = new Map() + turnChangeCards.forEach((card) => { + const renderIndex = + lastResponseIndexByTurnId.get(card.target.messageId) ?? + userIndexByTurnId.get(card.target.messageId) + if (renderIndex === undefined) return + const existing = cardsByRenderIndex.get(renderIndex) + if (existing) { + existing.push(card) + } else { + cardsByRenderIndex.set(renderIndex, [card]) + } + }) + + return cardsByRenderIndex +} + +function getApiErrorMessage(error: unknown) { + return error instanceof ApiError + ? typeof error.body === 'object' && error.body && 'message' in error.body + ? String((error.body as { message: unknown }).message) + : error.message + : error instanceof Error + ? error.message + : String(error) +} + +function isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint { + if (!value || typeof value !== 'object') return false + const checkpoint = value as Partial + return ( + Boolean(checkpoint.target) && + typeof checkpoint.target?.targetUserMessageId === 'string' && + typeof checkpoint.target?.userMessageIndex === 'number' && + Boolean(checkpoint.code) && + typeof checkpoint.code?.available === 'boolean' && + Array.isArray(checkpoint.code?.filesChanged) + ) +} + +function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] { + if (!response || typeof response !== 'object') return [] + const checkpoints = (response as { checkpoints?: unknown }).checkpoints + if (!Array.isArray(checkpoints)) return [] + return checkpoints.filter(isSessionTurnCheckpoint) +} + +type MessageListProps = { + sessionId?: string | null + compact?: boolean +} + +const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 + +function isNearScrollBottom(element: HTMLElement) { + return ( + element.scrollHeight - element.scrollTop - element.clientHeight <= + AUTO_SCROLL_BOTTOM_THRESHOLD_PX + ) +} + +export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) - const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const resolvedSessionId = sessionId ?? activeTabId + const sessionState = useChatStore((s) => + resolvedSessionId ? s.sessions[resolvedSessionId] : undefined, + ) + const stopGeneration = useChatStore((s) => s.stopGeneration) + const reloadHistory = useChatStore((s) => s.reloadHistory) + const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) + const isMemberSession = useTeamStore((s) => + resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, + ) + const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? [] const chatState = sessionState?.chatState ?? 'idle' const streamingText = sessionState?.streamingText ?? '' const activeThinkingId = sessionState?.activeThinkingId ?? null const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} + const scrollContainerRef = useRef(null) const bottomRef = useRef(null) + const shouldAutoScrollRef = useRef(true) + const lastSessionIdRef = useRef(resolvedSessionId) + const t = useTranslation() + const [turnChangeCards, setTurnChangeCards] = useState([]) + const [turnChangeLoadError, setTurnChangeLoadError] = useState(null) + const [turnActionErrors, setTurnActionErrors] = useState>({}) + const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false) + const [rewindingTurnId, setRewindingTurnId] = useState(null) + const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState(null) + + const updateAutoScrollState = useCallback(() => { + const container = scrollContainerRef.current + if (!container) return + shouldAutoScrollRef.current = isNearScrollBottom(container) + }, []) useEffect(() => { + if (lastSessionIdRef.current !== resolvedSessionId) { + shouldAutoScrollRef.current = true + lastSessionIdRef.current = resolvedSessionId + } + + if (!shouldAutoScrollRef.current) return + bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) - }, [messages.length, streamingText]) + }, [messages.length, resolvedSessionId, streamingText]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), [messages], ) + const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages]) + const latestCompletedTurnId = + completedTurnTargets.length > 0 + ? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null + : null + const turnCardsByRenderIndex = useMemo( + () => buildTurnCardInsertionMap(renderItems, turnChangeCards), + [renderItems, turnChangeCards], + ) + const confirmTurnCard = useMemo( + () => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null, + [turnChangeCards, turnUndoConfirmTargetId], + ) + + useEffect(() => { + if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) { + setTurnChangeCards([]) + setTurnChangeLoadError(null) + setIsLoadingTurnChangeCards(false) + return + } + + if (chatState !== 'idle') { + setTurnChangeLoadError(null) + setIsLoadingTurnChangeCards(false) + return + } + + let cancelled = false + setIsLoadingTurnChangeCards(true) + setTurnChangeLoadError(null) + + Promise.all([ + sessionsApi.getTurnCheckpoints(resolvedSessionId), + sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null), + ]) + .then(([checkpointResponse, workspaceStatus]) => { + if (cancelled) return + const targetByMessageId = new Map( + completedTurnTargets.map((target) => [target.messageId, target] as const), + ) + const targetByUserMessageIndex = new Map( + completedTurnTargets.map((target) => [target.userMessageIndex, target] as const), + ) + + setTurnChangeCards( + normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => { + const target = + targetByMessageId.get(checkpoint.target.targetUserMessageId) ?? + targetByUserMessageIndex.get(checkpoint.target.userMessageIndex) + if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) { + return [] + } + return [{ + target, + checkpoint, + workDir: checkpoint.workDir ?? workspaceStatus?.workDir ?? null, + isLatest: target.messageId === latestCompletedTurnId, + }] + }), + ) + }) + .catch((error) => { + if (cancelled) return + setTurnChangeCards([]) + setTurnChangeLoadError(getApiErrorMessage(error)) + }) + .finally(() => { + if (!cancelled) { + setIsLoadingTurnChangeCards(false) + } + }) + + return () => { + cancelled = true + } + }, [chatState, completedTurnTargets, isMemberSession, latestCompletedTurnId, resolvedSessionId]) + + const handleUndoCurrentTurn = useCallback(async () => { + if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId) return + + const target = confirmTurnCard.target + setRewindingTurnId(target.messageId) + setTurnActionErrors((current) => { + if (!(target.messageId in current)) return current + const next = { ...current } + delete next[target.messageId] + return next + }) + + try { + if (chatState !== 'idle') { + stopGeneration(resolvedSessionId) + } + + const result = await sessionsApi.rewind(resolvedSessionId, { + targetUserMessageId: target.messageId, + userMessageIndex: target.userMessageIndex, + expectedContent: target.expectedContent, + }) + + await reloadHistory(resolvedSessionId) + queueComposerPrefill(resolvedSessionId, { + text: target.content, + attachments: target.attachments, + }) + + addToast({ + type: 'success', + message: result.code.available + ? t('chat.rewindSuccessWithCode', { + count: result.conversation.messagesRemoved, + }) + : t('chat.rewindSuccessConversationOnly', { + count: result.conversation.messagesRemoved, + }), + }) + + setTurnUndoConfirmTargetId(null) + } catch (error) { + setTurnActionErrors((current) => ({ + ...current, + [target.messageId]: getApiErrorMessage(error), + })) + setTurnUndoConfirmTargetId(null) + } finally { + setRewindingTurnId(null) + } + }, [ + addToast, + chatState, + confirmTurnCard, + queueComposerPrefill, + reloadHistory, + resolvedSessionId, + rewindingTurnId, + stopGeneration, + t, + ]) return ( -
-
- {renderItems.map((item) => { - if (item.kind === 'tool_group') { - return ( - !toolResultMap.has(tc.toolUseId)) - } - /> - ) - } +
+
+ {renderItems.map((item, index) => { + const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] - const msg = item.message return ( - { - const r = toolResultMap.get(msg.toolUseId) - return r ? { content: r.content, isError: r.isError } : null - })() - : null - } - /> +
+ {item.kind === 'tool_group' ? ( + !toolResultMap.has(tc.toolUseId)) + } + /> + ) : ( + { + const result = toolResultMap.get(item.message.toolUseId) + return result ? { content: result.content, isError: result.isError } : null + })() + : null + } + /> + )} + + {resolvedSessionId && cardsForItem.map((card) => ( + { + setTurnUndoConfirmTargetId(card.target.messageId) + }} + /> + ))} +
) })} - {streamingText && ( + {streamingText.trim() && ( )} @@ -178,8 +515,36 @@ export function MessageList() { )} + {!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && ( +
+ {turnChangeLoadError} +
+ )} +
+ + { + if (!rewindingTurnId) { + setTurnUndoConfirmTargetId(null) + } + }} + onConfirm={handleUndoCurrentTurn} + title={confirmTurnCard?.isLatest + ? t('chat.turnChangesLatestConfirmTitle') + : t('chat.turnChangesHistoricalConfirmTitle')} + body={confirmTurnCard?.isLatest + ? t('chat.turnChangesLatestConfirmBody') + : t('chat.turnChangesHistoricalConfirmBody')} + confirmLabel={confirmTurnCard?.isLatest + ? t('chat.turnChangesLatestConfirmUndo') + : t('chat.turnChangesHistoricalConfirmUndo')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={Boolean(rewindingTurnId)} + />
) } @@ -199,7 +564,12 @@ export const MessageBlock = memo(function MessageBlock({ switch (message.type) { case 'user_text': - return + return ( + + ) case 'assistant_text': return case 'thinking': diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index e1de3566..8e12ac17 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -127,7 +127,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr const allowRawToggle = !preview return ( -
+
{verb}... {elapsedSeconds > 0 && ( diff --git a/desktop/src/components/chat/ThinkingBlock.tsx b/desktop/src/components/chat/ThinkingBlock.tsx index dc97706e..3975525d 100644 --- a/desktop/src/components/chat/ThinkingBlock.tsx +++ b/desktop/src/components/chat/ThinkingBlock.tsx @@ -18,7 +18,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string; const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine return ( -
+
) : outputSummary ? ( -
-
- {outputSummary} -
-
- - {t('agentStatus.viewResult')} - -
+
+ {t('agentStatus.noActivity')}
) : (
@@ -552,6 +545,19 @@ function getAgentOutputSummary(content: string): string { return text.length > 220 ? `${text.slice(0, 220)}...` : text } +function extractAgentDisplayText(content: unknown): string { + return stripAgentResultMetadata(extractTextContent(content)) +} + +function stripAgentResultMetadata(text: string): string { + return text + .replace(/^\s*agentId:.*(?:\r?\n)?/gm, '') + .replace(/[\s\S]*?<\/usage>/g, '') + .replace(/^\s*(?:total_tokens|tool_uses|duration_ms):\s*\d+\s*$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + function isAgentLaunchResult(content: unknown): boolean { const text = extractTextContent(content).trim() if (!text) return false diff --git a/desktop/src/components/chat/ToolResultBlock.tsx b/desktop/src/components/chat/ToolResultBlock.tsx index 16ef5be6..791ae1b1 100644 --- a/desktop/src/components/chat/ToolResultBlock.tsx +++ b/desktop/src/components/chat/ToolResultBlock.tsx @@ -27,7 +27,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true const hasMore = text.length > 200 return ( -
0 return ( -
-
+
+
{attachments && attachments.length > 0 && ( )} @@ -25,14 +28,15 @@ export function UserMessage({ content, attachments }: Props) { {content}
)} -
- {hasText && ( - - )} + {hasText && ( + + )} +
) } diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 270144e2..de59a471 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -4,10 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock' import { ToolCallBlock } from './ToolCallBlock' import { PermissionDialog } from './PermissionDialog' import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' describe('chat blocks', () => { beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ sessions: {} }) }) diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 871872dd..ff39605d 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -4,6 +4,7 @@ import { insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, + resolveSlashUiAction, } from './composerUtils' describe('composerUtils', () => { @@ -35,8 +36,9 @@ describe('composerUtils', () => { ]), ).toEqual( expect.arrayContaining([ - { name: 'help', description: 'Show available commands' }, + { name: 'help', description: 'Show available desktop and agent commands' }, { name: 'clear', description: 'Clear conversation history' }, + { name: 'context', description: 'Show current context usage' }, ]), ) }) @@ -52,4 +54,16 @@ describe('composerUtils', () => { ]), ) }) + + it('resolves hidden settings aliases without displaying duplicate fallback rows', () => { + expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) + expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin') + expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins') + }) + + it('routes session inspection commands to the desktop panel', () => { + expect(resolveSlashUiAction('cost')).toEqual({ type: 'panel', command: 'cost' }) + expect(resolveSlashUiAction('context')).toEqual({ type: 'panel', command: 'context' }) + expect(resolveSlashUiAction('status')).toEqual({ type: 'panel', command: 'status' }) + }) }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index c263f2c7..50292392 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -1,21 +1,39 @@ +import type { SettingsTab } from '../../stores/uiStore' + +export const PANEL_SLASH_COMMANDS = [ + { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, + { name: 'skills', description: 'Browse user-invocable skills for the current chat context' }, + { name: 'help', description: 'Show available desktop and agent commands' }, + { name: 'status', description: 'Show session status, usage, and context' }, + { name: 'cost', description: 'Show session usage and costs' }, + { name: 'context', description: 'Show current context usage' }, +] as const + +export const SETTINGS_SLASH_COMMANDS = [ + { name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, +] as const + +export const SLASH_COMMAND_ALIASES = [ + { name: 'plugins', target: 'plugin' }, +] as const + export const FALLBACK_SLASH_COMMANDS = [ + ...PANEL_SLASH_COMMANDS, + ...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })), { name: 'compact', description: 'Compact conversation context' }, { name: 'clear', description: 'Clear conversation history' }, - { name: 'help', description: 'Show available commands' }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, { name: 'pr', description: 'Create a pull request' }, { name: 'init', description: 'Initialize project CLAUDE.md' }, { name: 'bug', description: 'Report a bug' }, { name: 'config', description: 'Open configuration' }, - { name: 'cost', description: 'Show token usage and costs' }, { name: 'doctor', description: 'Diagnose installation issues' }, { name: 'login', description: 'Switch Anthropic accounts' }, { name: 'logout', description: 'Sign out of current account' }, { name: 'memory', description: 'Edit CLAUDE.md memory files' }, { name: 'model', description: 'Switch AI model' }, { name: 'permissions', description: 'View or manage tool permissions' }, - { name: 'status', description: 'Show project and session status' }, { name: 'terminal-setup', description: 'Set up terminal integration' }, { name: 'vim', description: 'Toggle vim editing mode' }, ] @@ -25,6 +43,31 @@ export type SlashCommandOption = { description: string } +export type SlashUiAction = + | { + type: 'panel' + command: typeof PANEL_SLASH_COMMANDS[number]['name'] + } + | { + type: 'settings' + tab: SettingsTab + } + +export function resolveSlashUiAction(value: string): SlashUiAction | null { + const normalizedValue = SLASH_COMMAND_ALIASES.find((alias) => alias.name === value)?.target ?? value + const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === normalizedValue) + if (panelCommand) { + return { type: 'panel', command: panelCommand.name } + } + + const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === normalizedValue) + if (settingsCommand) { + return { type: 'settings', tab: settingsCommand.tab } + } + + return null +} + export function mergeSlashCommands( preferred: ReadonlyArray, fallback: ReadonlyArray = FALLBACK_SLASH_COMMANDS, diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index fad9c9e9..0605575c 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -1,26 +1,134 @@ -import { useState, useRef, useEffect } from 'react' -import { useSettingsStore } from '../../stores/settingsStore' +import { useEffect, useMemo, useRef, useState } from 'react' +import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' import { useTranslation } from '../../i18n' -import type { EffortLevel } from '../../types/settings' +import { useChatStore } from '../../stores/chatStore' +import { useProviderStore } from '../../stores/providerStore' +import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' +import { useSettingsStore } from '../../stores/settingsStore' +import type { SavedProvider } from '../../types/provider' +import type { RuntimeSelection } from '../../types/runtime' +import type { EffortLevel, ModelInfo } from '../../types/settings' -const MODEL_ICONS = { - opus: 'diamond', - sonnet: 'auto_awesome', - haiku: 'bolt', -} as const - -type Props = { - /** Controlled mode: model ID override */ - value?: string - /** Controlled mode: called on change instead of updating global store */ - onChange?: (modelId: string) => void +type ProviderChoice = { + providerId: string | null + providerName: string + isDefault: boolean + models: ModelInfo[] } -export function ModelSelector({ value, onChange }: Props = {}) { +type Props = { + value?: string + onChange?: (modelId: string) => void + runtimeKey?: string + disabled?: boolean + compact?: boolean +} + +function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { + return { + providerId: null, + providerName: officialName, + isDefault, + models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS, + } +} + +function buildProviderModels( + provider: SavedProvider, + labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, +): ModelInfo[] { + const entries: Array<{ id: string; label: string }> = [ + { id: provider.models.main.trim(), label: labels.main }, + { id: provider.models.haiku.trim(), label: labels.haiku }, + { id: provider.models.sonnet.trim(), label: labels.sonnet }, + { id: provider.models.opus.trim(), label: labels.opus }, + ] + + const byId = new Map() + for (const entry of entries) { + if (!entry.id) continue + const existing = byId.get(entry.id) + if (existing) { + if (!existing.labels.includes(entry.label)) { + existing.labels.push(entry.label) + } + continue + } + byId.set(entry.id, { id: entry.id, labels: [entry.label] }) + } + + return [...byId.values()].map((entry) => ({ + id: entry.id, + name: entry.id, + description: entry.labels.join(' · '), + context: '', + })) +} + +function buildProviderChoices( + providers: SavedProvider[], + activeId: string | null, + availableModels: ModelInfo[], + officialName: string, + labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, +): ProviderChoice[] { + return [ + officialChoices(availableModels, activeId === null, officialName), + ...providers.map((provider) => ({ + providerId: provider.id, + providerName: provider.name, + isDefault: activeId === provider.id, + models: buildProviderModels(provider, labels), + })), + ] +} + +function resolveDefaultRuntimeSelection( + activeId: string | null, + activeProviderName: string | null, + providers: SavedProvider[], + currentModelId: string | undefined, +): RuntimeSelection { + const inferredProviderId = activeId ?? ( + activeProviderName + ? providers.find((provider) => provider.name === activeProviderName)?.id ?? null + : null + ) + + return { + providerId: inferredProviderId, + modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID, + } +} + +export function ModelSelector({ + value, + onChange, + runtimeKey, + disabled = false, + compact = false, +}: Props = {}) { const t = useTranslation() - const { currentModel: storeModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore() + const { + currentModel: storeModel, + availableModels, + effortLevel, + activeProviderName, + setModel, + setEffort, + } = useSettingsStore() + const { + providers, + activeId, + isLoading: providersLoading, + fetchProviders, + } = useProviderStore() + const runtimeSelection = useSessionRuntimeStore((state) => + runtimeKey ? state.selections[runtimeKey] : undefined, + ) const [open, setOpen] = useState(false) const ref = useRef(null) + const requestedProvidersRef = useRef(false) const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [ { value: 'low', label: t('settings.general.effort.low') }, @@ -30,7 +138,13 @@ export function ModelSelector({ value, onChange }: Props = {}) { ] const isControlled = value !== undefined - const selectedModel = isControlled ? availableModels.find((m) => m.id === value) || null : storeModel + const isRuntimeScoped = !isControlled && runtimeKey !== undefined + + useEffect(() => { + if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return + requestedProvidersRef.current = true + void fetchProviders() + }, [fetchProviders, isRuntimeScoped, providersLoading]) useEffect(() => { if (!open) return @@ -48,107 +162,236 @@ export function ModelSelector({ value, onChange }: Props = {}) { } }, [open]) - const getModelIcon = (id: string): string => { - const lower = id.toLowerCase() - if (lower.includes('opus')) return MODEL_ICONS.opus - if (lower.includes('sonnet')) return MODEL_ICONS.sonnet - if (lower.includes('haiku')) return MODEL_ICONS.haiku - return 'smart_toy' + const roleLabels = useMemo( + () => ({ + main: t('settings.providers.mainModel'), + haiku: t('settings.providers.haikuModel'), + sonnet: t('settings.providers.sonnetModel'), + opus: t('settings.providers.opusModel'), + }), + [t], + ) + + const providerChoices = useMemo( + () => buildProviderChoices( + providers, + activeId, + activeId === null ? availableModels : OFFICIAL_MODELS, + t('settings.providers.officialName'), + roleLabels, + ), + [activeId, availableModels, providers, roleLabels, t], + ) + + const selectedModel = isControlled + ? availableModels.find((model) => model.id === value) || null + : storeModel + + const activeRuntimeSelection = isRuntimeScoped + ? runtimeSelection ?? resolveDefaultRuntimeSelection( + activeId, + activeProviderName, + providers, + storeModel?.id, + ) + : null + + const selectedProviderChoice = activeRuntimeSelection + ? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null + : null + + const selectedRuntimeModel = activeRuntimeSelection + ? selectedProviderChoice?.models.find((model) => model.id === activeRuntimeSelection.modelId) + ?? { + id: activeRuntimeSelection.modelId, + name: activeRuntimeSelection.modelId, + description: '', + context: '', + } + : null + + const buttonModelLabel = isRuntimeScoped + ? selectedRuntimeModel?.name ?? storeModel?.name ?? t('model.selectModel') + : selectedModel?.name ?? t('model.selectModel') + const buttonProviderLabel = isRuntimeScoped + ? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName') + : null + + const handleRuntimeSelect = (selection: RuntimeSelection) => { + if (!runtimeKey) return + useSessionRuntimeStore.getState().setSelection(runtimeKey, selection) + if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) { + useChatStore.getState().setSessionRuntime(runtimeKey, selection) + } + setOpen(false) } return (
{open && ( -
- {/* Models */} -
-
+
+
+
{t('model.configuration')}
-
- {availableModels.map((model) => { - const isSelected = model.id === selectedModel?.id - return ( - + ) + })}
- - ) - })} -
+
+ ))} +
+ ) : ( +
+ {availableModels.map((model) => { + const isSelected = model.id === selectedModel?.id + return ( + + ) + })} +
+ )}
- {/* Effort — hidden in controlled mode (not relevant for task creation) */} - {!isControlled &&
-
- {t('model.effort')} + {!isControlled && !isRuntimeScoped && ( +
+
+ {t('model.effort')} +
+
+ {EFFORT_OPTIONS.map((opt) => { + const isSelected = opt.value === effortLevel + return ( + + ) + })} +
-
- {EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel - return ( - - ) - })} -
-
} + )}
)}
diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index dbffda49..dced8e02 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -18,13 +18,14 @@ const MODE_ICONS: Record = { type Props = { workDir?: string + compact?: boolean /** Controlled mode: override current value */ value?: PermissionMode /** Controlled mode: called on change instead of updating global store */ onChange?: (mode: PermissionMode) => void } -export function PermissionModeSelector({ workDir: workDirProp, value, onChange }: Props = {}) { +export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) { const t = useTranslation() const { permissionMode: storeMode, setPermissionMode } = useSettingsStore() const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode) @@ -104,11 +105,20 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
{open && ( diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 44245885..a1af3279 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -8,6 +8,7 @@ import { useUIStore, type SettingsTab } from '../../stores/uiStore' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' import { initializeDesktopServerUrl } from '../../lib/desktopRuntime' import { TabBar } from './TabBar' +import { StartupErrorView } from './StartupErrorView' import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useTranslation } from '../../i18n' @@ -29,8 +30,9 @@ export function AppShell() { // Restore tabs from localStorage await useTabStore.getState().restoreTabs() - const activeId = useTabStore.getState().activeTabId - if (activeId) { + const { activeTabId: activeId, tabs } = useTabStore.getState() + const activeTab = tabs.find((tab) => tab.sessionId === activeId) + if (activeId && activeTab?.type === 'session') { useChatStore.getState().connectToSession(activeId) } if (!cancelled) { @@ -72,18 +74,7 @@ export function AppShell() { useKeyboardShortcuts() if (startupError) { - return ( -
-
-

- {t('app.serverFailed')} -

-

- {startupError} -

-
-
- ) + return } if (!ready) { diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx new file mode 100644 index 00000000..1c67893e --- /dev/null +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../pages/EmptySession', () => ({ + EmptySession: () =>
, +})) + +vi.mock('../../pages/ActiveSession', () => ({ + ActiveSession: () =>
, +})) + +vi.mock('../../pages/ScheduledTasks', () => ({ + ScheduledTasks: () =>
, +})) + +vi.mock('../../pages/Settings', () => ({ + Settings: () =>
, +})) + +vi.mock('../../pages/TerminalSettings', () => ({ + TerminalSettings: ({ active, onNewTerminal, testId }: { active: boolean; onNewTerminal: () => void; testId: string }) => ( +
+ +
+ ), +})) + +import { ContentRouter } from './ContentRouter' +import { useTabStore } from '../../stores/tabStore' + +describe('ContentRouter terminal tabs', () => { + afterEach(() => { + useTabStore.setState({ tabs: [], activeTabId: null }) + }) + + it('renders the active terminal tab as main content', () => { + useTabStore.setState({ + tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }], + activeTabId: '__terminal__1', + }) + + render() + + expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'true') + expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() + }) + + it('keeps terminal tabs mounted while chat content is active', () => { + useTabStore.setState({ + tabs: [ + { sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }, + { sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' }, + ], + activeTabId: 'session-1', + }) + + render() + + expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'false') + expect(screen.getByTestId('active-session')).toBeInTheDocument() + }) + + it('can open another terminal tab from a terminal page', () => { + useTabStore.setState({ + tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }], + activeTabId: '__terminal__1', + }) + + render() + fireEvent.click(screen.getByRole('button', { name: 'New Terminal' })) + + expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2) + expect(useTabStore.getState().activeTabId).not.toBe('__terminal__1') + }) +}) diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 6e902210..c79176c1 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -1,27 +1,56 @@ +import type { ReactNode } from 'react' import { useTabStore } from '../../stores/tabStore' import { EmptySession } from '../../pages/EmptySession' import { ActiveSession } from '../../pages/ActiveSession' import { ScheduledTasks } from '../../pages/ScheduledTasks' import { Settings } from '../../pages/Settings' +import { TerminalSettings } from '../../pages/TerminalSettings' export function ContentRouter() { const activeTabId = useTabStore((s) => s.activeTabId) - const activeTabType = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId)?.type) + const tabs = useTabStore((s) => s.tabs) + const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type + const terminalTabs = tabs.filter((tab) => tab.type === 'terminal') - // No tabs open — show empty session + let page: ReactNode = null if (!activeTabId || !activeTabType) { - return + page = + } else if (activeTabType === 'settings') { + page = + } else if (activeTabType === 'scheduled') { + page = + } else if (activeTabType !== 'terminal') { + page = } - // Special tabs - if (activeTabType === 'settings') { - return - } - - if (activeTabType === 'scheduled') { - return - } - - // Session tab — ActiveSession handles both regular and member sessions - return + return ( +
+ {page && ( +
+ {page} +
+ )} + {terminalTabs.map((tab) => { + const active = tab.sessionId === activeTabId + const visible = activeTabType === 'terminal' && active + return ( +
+ useTabStore.getState().openTerminalTab()} + /> +
+ ) + })} +
+ ) } diff --git a/desktop/src/components/layout/ProjectFilter.tsx b/desktop/src/components/layout/ProjectFilter.tsx index f94fc0c2..3a370929 100644 --- a/desktop/src/components/layout/ProjectFilter.tsx +++ b/desktop/src/components/layout/ProjectFilter.tsx @@ -23,7 +23,7 @@ let cachedProjects: RecentProject[] | null = null let cacheTimestamp = 0 const CACHE_TTL = 30_000 -export function ProjectFilter() { +export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | 'embedded' }) { const t = useTranslation() const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore() const [open, setOpen] = useState(false) @@ -134,6 +134,7 @@ export function ProjectFilter() { : selectedProjects.length === 1 ? optionByPath.get(selectedProjects[0]!)?.title || fallbackProjectTitle(selectedProjects[0]!, t('sidebar.other')) : `${selectedProjects.length} projects` + const triggerLabel = isAllSelected ? t('sidebar.allProjects') : label const toggleProject = (projectPath: string) => { if (isAllSelected) { @@ -160,19 +161,45 @@ export function ProjectFilter() { type="button" onClick={() => setOpen((current) => !current)} aria-expanded={open} - className="flex items-center gap-1.5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm font-medium text-[var(--color-text-primary)] shadow-[0_1px_0_rgba(255,255,255,0.6)] transition-colors hover:bg-[var(--color-surface-hover)]" + aria-label={triggerLabel} + title={triggerLabel} + className={ + variant === 'embedded' + ? `inline-flex h-7 w-7 items-center justify-center rounded-[8px] border transition-colors duration-200 ${ + isAllSelected + ? 'border-transparent text-[var(--color-text-tertiary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-secondary)]' + : 'border-[var(--color-sidebar-item-active-border)] bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] hover:bg-[var(--color-sidebar-item-hover)]' + }` + : 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-[10px] border border-[var(--color-sidebar-filter-border)] bg-[var(--color-sidebar-filter-bg)] px-2 text-left text-[14px] text-[var(--color-text-primary)] transition-colors duration-200 hover:bg-[var(--color-sidebar-item-hover)]' + } > - {label} - + {variant === 'embedded' ? ( + + + {!isAllSelected && ( + + )} + + ) : ( + <> + + + {label} + + + + + + )} {open && dropdownPos && createPortal(
- +
{t('sidebar.allProjects')}
@@ -207,11 +238,13 @@ export function ProjectFilter() { key={option.projectPath} type="button" onClick={() => toggleProject(option.projectPath)} - className={`flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${ - checked ? 'bg-[var(--color-surface-selected)]' : '' + className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${ + checked + ? 'bg-[var(--color-sidebar-item-active)]' + : 'hover:bg-[var(--color-sidebar-item-hover)]' }`} > - {option.isGit ? : } + {option.isGit ? : }
{option.title}
{option.subtitle && ( @@ -277,7 +310,7 @@ function ChevronIcon({ open }: { open: boolean }) { ) } -function FolderIcon() { +function FolderIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) { return ( ) } -function GitBranchIcon() { +function GitBranchIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) { return ( diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index c98ef8fe..fd612e55 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' vi.mock('./ProjectFilter', () => ({ @@ -17,6 +17,8 @@ vi.mock('../../i18n', () => ({ 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', 'common.retry': 'Retry', + 'common.loading': 'Loading...', + 'common.cancel': 'Cancel', 'common.delete': 'Delete', 'common.rename': 'Rename', 'sidebar.timeGroup.today': 'Today', @@ -25,6 +27,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.timeGroup.last30days': 'Last 30 Days', 'sidebar.timeGroup.older': 'Older', 'sidebar.missingDir': 'Missing', + 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', } @@ -121,7 +124,7 @@ describe('Sidebar', () => { expect(useTabStore.getState().tabs).toEqual([]) }) - it('removes the matching tab when deleting a session from the sidebar', async () => { + it('requires confirmation before deleting a session from the sidebar', async () => { deleteSession.mockResolvedValue(undefined) useSessionStore.setState({ sessions: [ @@ -146,8 +149,15 @@ describe('Sidebar', () => { fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ })) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + expect(deleteSession).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() + expect(screen.getByText('Delete this session? This cannot be undone.')).toBeInTheDocument() + await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) }) await waitFor(() => { @@ -192,4 +202,13 @@ describe('Sidebar', () => { expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') }) + + it('shows a loading state instead of an empty session list while initial fetch is pending', () => { + useSessionStore.setState({ isLoading: true, sessions: [] }) + + render() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.queryByText('No sessions')).not.toBeInTheDocument() + }) }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 98ca2a70..c8433899 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import { ProjectFilter } from './ProjectFilter' +import { ConfirmDialog } from '../shared/ConfirmDialog' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' @@ -17,6 +18,7 @@ const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last3 export function Sidebar() { const sessions = useSessionStore((s) => s.sessions) const selectedProjects = useSessionStore((s) => s.selectedProjects) + const isLoading = useSessionStore((s) => s.isLoading) const error = useSessionStore((s) => s.error) const fetchSessions = useSessionStore((s) => s.fetchSessions) const deleteSession = useSessionStore((s) => s.deleteSession) @@ -29,6 +31,7 @@ export function Sidebar() { const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) + const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') @@ -61,18 +64,25 @@ export function Sidebar() { }, [sessions, selectedProjects, searchQuery]) const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions]) + const showInitialLoading = isLoading && sessions.length === 0 const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { e.preventDefault() setContextMenu({ id, x: e.clientX, y: e.clientY }) }, []) - const handleDelete = useCallback(async (id: string) => { + const handleDelete = useCallback((id: string) => { setContextMenu(null) - await deleteSession(id) - disconnectSession(id) - closeTab(id) - }, [closeTab, deleteSession, disconnectSession]) + setPendingDeleteSessionId(id) + }, []) + + const confirmDelete = useCallback(async () => { + if (!pendingDeleteSessionId) return + await deleteSession(pendingDeleteSessionId) + disconnectSession(pendingDeleteSessionId) + closeTab(pendingDeleteSessionId) + setPendingDeleteSessionId(null) + }, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId]) const handleStartRename = useCallback((id: string, currentTitle: string) => { setContextMenu(null) @@ -125,7 +135,7 @@ export function Sidebar() {
- +
-
- +
+ +
-
- setSearchQuery(e.target.value)} - className="w-full h-8 px-2.5 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-border-focus)]" - /> -
-
-
+
{error && (
{t('sidebar.sessionListFailed')}
@@ -236,7 +247,11 @@ export function Sidebar() {
)} - {filteredSessions.length === 0 && ( + {showInitialLoading ? ( +
+ {t('common.loading')} +
+ ) : filteredSessions.length === 0 && (
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
@@ -246,7 +261,7 @@ export function Sidebar() { if (!items || items.length === 0) return null return (
-
+
{timeGroupLabels[group]}
{items.map((session) => ( @@ -274,22 +289,22 @@ export function Sidebar() { }} onContextMenu={(e) => handleContextMenu(e, session.id)} className={` - group w-full rounded-[var(--radius-md)] py-1.5 pl-4 pr-3 text-left text-sm transition-colors duration-200 + group w-full rounded-[12px] px-3 py-2 text-left text-sm transition-colors duration-200 ${session.id === activeTabId - ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' - : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]' + ? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]' + : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]' } `} > - + - {session.title || 'Untitled'} + {session.title || 'Untitled'} {!session.workDirExists && (
)} + + setPendingDeleteSessionId(null)} + onConfirm={confirmDelete} + title={t('common.delete')} + body={pendingDeleteSessionId ? t('sidebar.confirmDelete') : ''} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + /> ) } @@ -399,11 +425,11 @@ function NavItem({ aria-label={label} title={collapsed ? label : undefined} className={` - flex items-center rounded-[var(--radius-md)] transition-all duration-200 - ${collapsed ? 'h-10 w-10 justify-center px-0 py-0' : 'w-full gap-2.5 px-3 py-2 text-sm'} + flex items-center transition-colors duration-200 + ${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : 'w-full gap-2.5 rounded-[12px] px-3 py-2.5 text-sm'} ${active - ? 'bg-[var(--color-surface-selected)] font-medium text-[var(--color-text-primary)] shadow-[0_8px_24px_rgba(15,23,42,0.08)]' - : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]' + ? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]' + : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]' } `} > @@ -455,6 +481,15 @@ function ClockIcon() { ) } +function SearchIcon() { + return ( + + + + + ) +} + function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) { return ( { + it('separates the timeout message from captured sidecar logs', () => { + const result = splitStartupError( + 'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds\n\nRecent server logs:\n[stderr] failed to bind\n[exit] sidecar exited (code=1, signal=None)', + ) + + expect(result.message).toBe( + 'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds', + ) + expect(result.logs).toContain('[stderr] failed to bind') + expect(result.diagnostics).toContain('Recent server logs:') + }) +}) + +describe('StartupErrorView', () => { + it('shows diagnostics and copies the full payload', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + render( + , + ) + + expect(screen.getByText('本地服务启动失败')).toBeInTheDocument() + expect(screen.getByText('startup failed')).toBeInTheDocument() + expect(screen.getByText('[stderr] boom')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' })) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'startup failed\n\nRecent server logs:\n[stderr] boom', + ) + }) + expect(screen.getByText('已复制')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx new file mode 100644 index 00000000..7238fcfa --- /dev/null +++ b/desktop/src/components/layout/StartupErrorView.tsx @@ -0,0 +1,99 @@ +import { Copy, RefreshCw } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from '../../i18n' +import { Button } from '../shared/Button' + +const LOG_MARKER = '\n\nRecent server logs:\n' + +export function splitStartupError(error: string) { + const markerIndex = error.indexOf(LOG_MARKER) + if (markerIndex === -1) { + return { + message: error, + logs: '', + diagnostics: error, + } + } + + const message = error.slice(0, markerIndex).trim() + const logs = error.slice(markerIndex + LOG_MARKER.length).trim() + return { + message, + logs, + diagnostics: `${message}\n\nRecent server logs:\n${logs}`, + } +} + +type StartupErrorViewProps = { + error: string +} + +export function StartupErrorView({ error }: StartupErrorViewProps) { + const t = useTranslation() + const { message, logs, diagnostics } = useMemo(() => splitStartupError(error), [error]) + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard?.writeText(diagnostics) + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + } + + return ( +
+
+
+
+

+ {t('app.serverFailed')} +

+

+ {t('app.serverFailedHint')} +

+
+ +
+
+ {t('app.startupError')} +
+
+              {message}
+            
+
+ + {logs ? ( +
+
+ {t('app.serverLogs')} +
+
+                {logs}
+              
+
+ ) : null} + +
+ + +
+
+
+
+ ) +} diff --git a/desktop/src/components/layout/StatusBar.tsx b/desktop/src/components/layout/StatusBar.tsx index e95ad7b0..82c742d1 100644 --- a/desktop/src/components/layout/StatusBar.tsx +++ b/desktop/src/components/layout/StatusBar.tsx @@ -1,15 +1,20 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTabStore } from '../../stores/tabStore' export function StatusBar() { const { currentModel } = useSettingsStore() const activeTabId = useTabStore((s) => s.activeTabId) + const runtimeSelection = useSessionRuntimeStore((s) => + activeTabId ? s.selections[activeTabId] : undefined, + ) const projectPath = useSessionStore((s) => s.sessions.find((session) => session.id === activeTabId)?.projectPath) const projectName = projectPath ? projectPath.split('-').filter(Boolean).pop() || '' : '' + const modelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? null return (
@@ -20,9 +25,9 @@ export function StatusBar() {
- {currentModel && ( + {modelLabel && ( - {currentModel.name} + {modelLabel} )}
diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 01a6b6e1..1239d821 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -6,6 +6,9 @@ const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve())) const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({ startDragging: startDraggingMock, }))) +const windowControlsMock = vi.hoisted(() => ({ + show: true, +})) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: getCurrentWindowMock, @@ -23,6 +26,9 @@ vi.mock('../../i18n', () => ({ 'tabs.closeConfirmMessage': 'Still running', 'tabs.closeConfirmKeep': 'Keep Running', 'tabs.closeConfirmStop': 'Stop & Close', + 'tabs.openTerminal': 'Open Terminal', + 'tabs.showWorkspace': 'Show Workspace', + 'tabs.hideWorkspace': 'Hide Workspace', 'common.cancel': 'Cancel', } @@ -31,8 +37,10 @@ vi.mock('../../i18n', () => ({ })) vi.mock('./WindowControls', () => ({ - WindowControls: () =>
, - showWindowControls: true, + WindowControls: () => (windowControlsMock.show ?
: null), + get showWindowControls() { + return windowControlsMock.show + }, })) describe('TabBar', () => { @@ -58,17 +66,20 @@ describe('TabBar', () => { startDraggingMock.mockClear() getCurrentWindowMock.mockClear() + windowControlsMock.show = true vi.resetModules() }) afterEach(async () => { const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') useTabStore.setState({ tabs: [], activeTabId: null }) useChatStore.setState({ sessions: {}, } as Partial>) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__ }) @@ -129,6 +140,25 @@ describe('TabBar', () => { expect(rightButton?.nextElementSibling).toBe(screen.getByTestId('window-controls')) }) + it('shows the terminal toolbar when no tabs are open', async () => { + windowControlsMock.show = false + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + + useTabStore.setState({ tabs: [], activeTabId: null }) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) + + const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') + expect(terminalTabs).toHaveLength(1) + expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId) + expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument() + }) + it('marks the tab bar as a native drag region', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') @@ -295,4 +325,183 @@ describe('TabBar', () => { expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-1', 'tab-2']) expect(useTabStore.getState().activeTabId).toBe('tab-1') }) + + it('closes a tab from the close button without activating drag behavior', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + const disconnectSession = vi.fn() + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + { sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-2', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession, + } as Partial>) + + await act(async () => { + render() + }) + + const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area') + const closeButton = screen.getByLabelText('Close First Session') + + expect(firstTab).toHaveClass('group') + + fireEvent.mouseDown(closeButton, { button: 0, clientX: 20, clientY: 10 }) + fireEvent.click(closeButton) + fireEvent.mouseMove(window, { clientX: 260, clientY: 10 }) + fireEvent.mouseUp(window) + + expect(disconnectSession).toHaveBeenCalledWith('tab-1') + expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2']) + expect(useTabStore.getState().activeTabId).toBe('tab-2') + }) + + it('closes terminal tabs without disconnecting chat sessions', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + const disconnectSession = vi.fn() + + useTabStore.setState({ + tabs: [ + { sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }, + ], + activeTabId: '__terminal__1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession, + } as Partial>) + + await act(async () => { + render() + }) + + expect(screen.getByLabelText('Open Terminal')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Close Terminal 1')) + + expect(disconnectSession).not.toHaveBeenCalled() + expect(useTabStore.getState().tabs).toEqual([]) + }) + + it('opens a terminal tab from the toolbar', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) + + const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') + expect(terminalTabs).toHaveLength(1) + expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId) + }) + + it('toggles the workspace panel for the active session from the toolbar', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Show Workspace' })) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Hide Workspace' })) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false) + }) + + it('hides the workspace toolbar button for non-session tabs', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }, + { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }, + ], + activeTabId: '__terminal__1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + const { rerender } = render() + + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + + await act(async () => { + useTabStore.getState().setActiveTab('__settings__') + }) + rerender() + + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + }) + + it('clears workspace panel state when closing a session tab', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + useWorkspacePanelStore.getState().openPanel('tab-1') + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByLabelText('Close First Session')) + + expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined() + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index f9fc3a96..4b52bbdc 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,8 +1,10 @@ import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' import { useTabStore, type Tab } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' +import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' const TAB_WIDTH = 180 const DRAG_START_THRESHOLD = 4 @@ -14,6 +16,11 @@ export function TabBar() { const setActiveTab = useTabStore((s) => s.setActiveTab) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) + const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null + const isActiveSessionTab = activeTab?.type === 'session' + const isWorkspacePanelOpen = useWorkspacePanelStore((state) => + activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, + ) const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -74,11 +81,19 @@ export function TabBar() { el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) } + const closeTabWithCleanup = useCallback((tab: Tab) => { + if (tab.type === 'session') { + useWorkspacePanelStore.getState().clearSession(tab.sessionId) + } + closeTab(tab.sessionId) + }, [closeTab]) + const handleClose = (sessionId: string) => { // Special tabs can always be closed directly const tab = tabs.find((t) => t.sessionId === sessionId) - if (tab && tab.type !== 'session') { - closeTab(sessionId) + if (!tab) return + if (tab.type !== 'session') { + closeTabWithCleanup(tab) return } @@ -91,7 +106,7 @@ export function TabBar() { } disconnectSession(sessionId) - closeTab(sessionId) + closeTabWithCleanup(tab) } const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { @@ -101,39 +116,38 @@ export function TabBar() { const handleCloseOthers = (sessionId: string) => { setContextMenu(null) - const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId) - for (const id of otherIds) { - disconnectSession(id) - closeTab(id) + const otherTabs = tabs.filter((t) => t.sessionId !== sessionId) + for (const tab of otherTabs) { + if (tab.type === 'session') disconnectSession(tab.sessionId) + closeTabWithCleanup(tab) } } const handleCloseLeft = (sessionId: string) => { setContextMenu(null) const idx = tabs.findIndex((t) => t.sessionId === sessionId) - const leftIds = tabs.slice(0, idx).map((t) => t.sessionId) - for (const id of leftIds) { - disconnectSession(id) - closeTab(id) + const leftTabs = tabs.slice(0, idx) + for (const tab of leftTabs) { + if (tab.type === 'session') disconnectSession(tab.sessionId) + closeTabWithCleanup(tab) } } const handleCloseRight = (sessionId: string) => { setContextMenu(null) const idx = tabs.findIndex((t) => t.sessionId === sessionId) - const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId) - for (const id of rightIds) { - disconnectSession(id) - closeTab(id) + const rightTabs = tabs.slice(idx + 1) + for (const tab of rightTabs) { + if (tab.type === 'session') disconnectSession(tab.sessionId) + closeTabWithCleanup(tab) } } const handleCloseAll = () => { setContextMenu(null) - const allIds = tabs.map((t) => t.sessionId) - for (const id of allIds) { - disconnectSession(id) - closeTab(id) + for (const tab of tabs) { + if (tab.type === 'session') disconnectSession(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -228,8 +242,6 @@ export function TabBar() { void startDragging().catch(() => {}) }, []) - if (tabs.length === 0 && !showWindowControls) return null - return (
+
+ } + label={t('tabs.openTerminal')} + onClick={() => useTabStore.getState().openTerminalTab()} + /> + {isActiveSessionTab && activeTabId && ( + : } + label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} + onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)} + active={isWorkspacePanelOpen} + /> + )} +
+ {isTauri && (
) }) TabItem.displayName = 'TabItem' + +function ToolbarIconButton({ + icon, + label, + onClick, + active = false, +}: { + icon: React.ReactNode + label: string + onClick: () => void + active?: boolean +}) { + return ( + + ) +} diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx new file mode 100644 index 00000000..14c0d897 --- /dev/null +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -0,0 +1,755 @@ +import { useMemo, useState, type ReactNode } from 'react' +import { usePluginStore } from '../../stores/pluginStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useTranslation } from '../../i18n' +import { useUIStore } from '../../stores/uiStore' +import { Button } from '../shared/Button' +import { ConfirmDialog } from '../shared/ConfirmDialog' +import type { PluginCapabilityKey } from '../../types/plugin' +import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { useSkillStore } from '../../stores/skillStore' +import { useAgentStore } from '../../stores/agentStore' +import { useMcpStore } from '../../stores/mcpStore' + +const CAPABILITY_ORDER: PluginCapabilityKey[] = [ + 'lspServers', +] + +export function PluginDetail() { + const { + selectedPlugin, + isDetailLoading, + isApplying, + clearSelection, + enablePlugin, + disablePlugin, + updatePlugin, + uninstallPlugin, + reloadPlugins, + } = usePluginStore() + const sessions = useSessionStore((s) => s.sessions) + const activeSessionId = useSessionStore((s) => s.activeSessionId) + const addToast = useUIStore((s) => s.addToast) + const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail) + const fetchAgents = useAgentStore((s) => s.fetchAgents) + const selectAgent = useAgentStore((s) => s.selectAgent) + const fetchServers = useMcpStore((s) => s.fetchServers) + const selectServer = useMcpStore((s) => s.selectServer) + const t = useTranslation() + const [actionKey, setActionKey] = useState(null) + const [showUninstallDialog, setShowUninstallDialog] = useState(false) + + const activeSession = sessions.find((session) => session.id === activeSessionId) + const currentWorkDir = activeSession?.workDir || undefined + + const otherCapabilityItems = useMemo( + () => + CAPABILITY_ORDER.map((key) => ({ + key, + items: selectedPlugin?.capabilities[key] ?? [], + })), + [selectedPlugin], + ) + + if (isDetailLoading) { + return ( +
+
+
+ ) + } + + if (!selectedPlugin) return null + + const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin' + const canNavigateSharedCapabilities = selectedPlugin.enabled + + const runAction = async (key: string, fn: () => Promise) => { + setActionKey(key) + try { + const message = await fn() + addToast({ type: 'success', message }) + } catch (err) { + addToast({ + type: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } finally { + setActionKey(null) + } + } + + const handleReload = async () => { + setActionKey('reload') + try { + const summary = await reloadPlugins(currentWorkDir) + addToast({ + type: summary.errors > 0 ? 'warning' : 'success', + message: t('settings.plugins.reloadToast', { + enabled: String(summary.enabled), + skills: String(summary.skills), + errors: String(summary.errors), + }), + }) + } catch (err) { + addToast({ + type: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } finally { + setActionKey(null) + } + } + + const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => { + useUIStore.getState().setPendingSettingsTab(tab) + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + } + + const handleOpenSkill = async (skillName: string) => { + if (!canNavigateSharedCapabilities) { + addToast({ + type: 'warning', + message: t('settings.plugins.sharedNavigationDisabled'), + }) + return + } + openSettingsTab('skills') + await fetchSkillDetail('plugin', skillName, currentWorkDir, 'plugins') + + const { selectedSkill, error } = useSkillStore.getState() + if (!selectedSkill && error) { + addToast({ type: 'error', message: error }) + } + } + + const handleOpenAgent = async (agentType: string) => { + if (!canNavigateSharedCapabilities) { + addToast({ + type: 'warning', + message: t('settings.plugins.sharedNavigationDisabled'), + }) + return + } + openSettingsTab('agents') + await fetchAgents(currentWorkDir) + + const state = useAgentStore.getState() + const agent = state.allAgents.find((entry) => entry.agentType === agentType) + if (!agent) { + addToast({ + type: 'error', + message: `Unable to locate agent: ${agentType}`, + }) + return + } + + selectAgent(agent, 'plugins') + } + + const handleOpenMcpServer = async (serverName: string) => { + if (!canNavigateSharedCapabilities) { + addToast({ + type: 'warning', + message: t('settings.plugins.sharedNavigationDisabled'), + }) + return + } + openSettingsTab('mcp') + await fetchServers(undefined, currentWorkDir) + + const state = useMcpStore.getState() + const server = state.servers.find((entry) => entry.name === serverName) + if (!server) { + addToast({ + type: 'error', + message: `Unable to locate MCP server: ${serverName}`, + }) + return + } + + selectServer(server) + } + + return ( +
+
+ +
+ +
+
+
+
+ {t('settings.plugins.entryEyebrow')} +
+
+

+ {selectedPlugin.name} +

+ + {t(`settings.plugins.scope.${selectedPlugin.scope}`)} + {selectedPlugin.marketplace} + {selectedPlugin.version && v{selectedPlugin.version}} +
+

+ {selectedPlugin.description || t('settings.plugins.noDescription')} +

+
+ {selectedPlugin.authorName && ( + {t('settings.plugins.author', { value: selectedPlugin.authorName })} + )} + {selectedPlugin.projectPath && ( + {t('settings.plugins.projectPath', { value: selectedPlugin.projectPath })} + )} + {selectedPlugin.installPath && ( + {t('settings.plugins.installPath', { value: selectedPlugin.installPath })} + )} +
+
+ +
+ + + + +
+
+
+ +
+
+ {canMutate && ( + selectedPlugin.enabled ? ( + + ) : ( + + ) + )} + + {canMutate && ( + + )} + + + + {canMutate && ( + + )} +
+ + {!canMutate && ( +

+ {selectedPlugin.scope === 'managed' + ? t('settings.plugins.managedHint') + : t('settings.plugins.builtinHint')} +

+ )} + +

+ {t('settings.plugins.applyHint')} +

+
+ + {selectedPlugin.errors.length > 0 && ( +
+
+ + error + +

+ {t('settings.plugins.errorsTitle')} +

+
+
+ {selectedPlugin.errors.map((error) => ( +
+ {error} +
+ ))} +
+
+ )} + +
+
+

+ {t('settings.plugins.capabilitiesTitle')} +

+

+ {t('settings.plugins.capabilitiesHint')} +

+
+
+ + {selectedPlugin.skillEntries.length > 0 ? ( +
+ {selectedPlugin.skillEntries.map((skill) => ( + void handleOpenSkill(skill.name)} + disabled={!canNavigateSharedCapabilities} + /> + ))} +
+ ) : null} +
+ + + {selectedPlugin.mcpServerEntries.length > 0 ? ( +
+ {selectedPlugin.mcpServerEntries.map((server) => ( + void handleOpenMcpServer(server.name)} + disabled={!canNavigateSharedCapabilities} + /> + ))} +
+ ) : null} +
+ + + {selectedPlugin.commandEntries.length > 0 ? ( +
+ {selectedPlugin.commandEntries.map((command) => ( + + ))} +
+ ) : null} +
+ + + {selectedPlugin.agentEntries.length > 0 ? ( +
+ {selectedPlugin.agentEntries.map((agent) => ( + void handleOpenAgent(agent.name)} + disabled={!canNavigateSharedCapabilities} + /> + ))} +
+ ) : null} +
+ + + {selectedPlugin.hookEntries.length > 0 ? ( +
+ {selectedPlugin.hookEntries.map((hook, index) => ( + + ))} +
+ ) : null} +
+ +
+ {otherCapabilityItems.map(({ key, items }) => ( +
+
+
+ {t(`settings.plugins.capabilityLabel.${key}`)} +
+ + {items.length} + +
+ {items.length > 0 ? ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ) : ( +
+ {t('settings.plugins.capabilityEmpty')} +
+ )} +
+ ))} +
+
+
+ + { + if (isApplying && actionKey === 'uninstall') return + setShowUninstallDialog(false) + }} + onConfirm={async () => { + setShowUninstallDialog(false) + await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir)) + }} + title={t('settings.plugins.uninstall')} + body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })} + confirmLabel={t('settings.plugins.uninstall')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isApplying && actionKey === 'uninstall'} + /> +
+ ) +} + +function CapabilityPreviewSection({ + title, + count, + children, + emptyLabel, + hint, +}: { + title: string + count: number + children: ReactNode + emptyLabel: string + hint?: string +}) { + return ( +
+
+
{title}
+
{count}
+
+
+ {hint && count > 0 && ( +
{hint}
+ )} + {count > 0 ? children : ( +
{emptyLabel}
+ )} +
+
+ ) +} + +function SkillPreviewCard({ + name, + rawName, + description, + version, + onClick, + disabled, +}: { + name: string + rawName?: string + description: string + version?: string + onClick: () => void + disabled?: boolean +}) { + const t = useTranslation() + const slashName = rawName || name + + return ( + + ) +} + +function CommandPreviewCard({ + name, + description, +}: { + name: string + description: string +}) { + return ( +
+
/{name}
+
{description}
+
+ ) +} + +function AgentPreviewCard({ + name, + description, + onClick, + disabled, +}: { + name: string + description: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function McpPreviewCard({ + name, + transport, + summary, + onClick, + disabled, +}: { + name: string + transport: string + summary: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function HookPreviewCard({ + event, + matcher, + actions, +}: { + event: string + matcher?: string + actions: string[] +}) { + return ( +
+
+ {event} + {matcher && ( + + {matcher} + + )} +
+
+ {actions.map((action) => ( + + {action} + + ))} +
+
+ ) +} + +function MetaPill({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function DetailStat({ + label, + value, + icon, +}: { + label: string + value: string + icon: string +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
+
+ ) +} + +function StatusPill({ + enabled, + hasErrors, +}: { + enabled: boolean + hasErrors: boolean +}) { + const t = useTranslation() + const classes = hasErrors + ? 'bg-[var(--color-error)]/12 text-[var(--color-error)]' + : enabled + ? 'bg-[var(--color-success-container)] text-[var(--color-success)]' + : 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]' + + const label = hasErrors + ? t('settings.plugins.status.attention') + : enabled + ? t('settings.plugins.status.enabled') + : t('settings.plugins.status.disabled') + + return ( + + {label} + + ) +} diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx new file mode 100644 index 00000000..7d8df76a --- /dev/null +++ b/desktop/src/components/plugins/PluginList.tsx @@ -0,0 +1,374 @@ +import { useEffect, useMemo } from 'react' +import { usePluginStore } from '../../stores/pluginStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useTranslation } from '../../i18n' +import { useUIStore } from '../../stores/uiStore' +import { Button } from '../shared/Button' +import type { PluginSummary } from '../../types/plugin' + +type PluginBucket = 'attention' | 'enabled' | 'disabled' + +export function PluginList() { + const { + plugins, + marketplaces, + summary, + lastReloadSummary, + isLoading, + isApplying, + error, + fetchPlugins, + fetchPluginDetail, + reloadPlugins, + } = usePluginStore() + const sessions = useSessionStore((s) => s.sessions) + const activeSessionId = useSessionStore((s) => s.activeSessionId) + const addToast = useUIStore((s) => s.addToast) + const t = useTranslation() + const activeSession = sessions.find((session) => session.id === activeSessionId) + const currentWorkDir = activeSession?.workDir || undefined + + useEffect(() => { + void fetchPlugins(currentWorkDir) + }, [fetchPlugins, currentWorkDir]) + + const grouped = useMemo(() => { + const buckets: Record = { + attention: [], + enabled: [], + disabled: [], + } + + for (const plugin of plugins) { + if (plugin.hasErrors) { + buckets.attention.push(plugin) + } else if (plugin.enabled) { + buckets.enabled.push(plugin) + } else { + buckets.disabled.push(plugin) + } + } + + return buckets + }, [plugins]) + + const handleReload = async () => { + try { + const reloadSummary = await reloadPlugins(currentWorkDir) + addToast({ + type: reloadSummary.errors > 0 ? 'warning' : 'success', + message: t('settings.plugins.reloadToast', { + enabled: String(reloadSummary.enabled), + skills: String(reloadSummary.skills), + errors: String(reloadSummary.errors), + }), + }) + } catch (err) { + addToast({ + type: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error) { + return
{error}
+ } + + if (plugins.length === 0) { + return ( +
+ + extension + +

+ {t('settings.plugins.empty')} +

+

+ {t('settings.plugins.emptyHint')} +

+
+ ) + } + + return ( +
+
+
+
+
+ {t('settings.plugins.browserEyebrow')} +
+
+ + extension + +

+ {t('settings.plugins.browserTitle')} +

+
+

+ {t('settings.plugins.browserDescription')} +

+ {lastReloadSummary && ( +

+ {t('settings.plugins.lastReload', { + enabled: String(lastReloadSummary.enabled), + skills: String(lastReloadSummary.skills), + errors: String(lastReloadSummary.errors), + })} +

+ )} +
+ +
+
+ + plugin.enabled).length)} + icon="check_circle" + /> + + +
+
+ + +
+
+
+
+ + {marketplaces.length > 0 && ( +
+
+

+ {t('settings.plugins.marketplacesTitle')} +

+

+ {t('settings.plugins.marketplacesHint')} +

+
+
+ {marketplaces.map((marketplace) => ( +
+
+ + {marketplace.name} + + + {marketplace.autoUpdate + ? t('settings.plugins.marketplaceAutoUpdateOn') + : t('settings.plugins.marketplaceAutoUpdateOff')} + +
+
+ {marketplace.source} +
+
+ {t('settings.plugins.marketplaceInstalledCount', { count: String(marketplace.installedCount) })} + {marketplace.lastUpdated && ( + {t('settings.plugins.marketplaceUpdatedAt', { value: new Date(marketplace.lastUpdated).toLocaleString() })} + )} +
+
+ ))} +
+
+ )} + + {renderGroup('attention', grouped.attention, fetchPluginDetail, currentWorkDir, t)} + {renderGroup('enabled', grouped.enabled, fetchPluginDetail, currentWorkDir, t)} + {renderGroup('disabled', grouped.disabled, fetchPluginDetail, currentWorkDir, t)} +
+ ) +} + +function renderGroup( + bucket: PluginBucket, + items: PluginSummary[], + fetchPluginDetail: (id: string, cwd?: string) => Promise, + cwd: string | undefined, + t: ReturnType, +) { + if (items.length === 0) return null + + const titleKey = + bucket === 'attention' + ? 'settings.plugins.group.attention' + : bucket === 'enabled' + ? 'settings.plugins.group.enabled' + : 'settings.plugins.group.disabled' + + return ( +
+
+
+

+ {t(titleKey)} +

+

+ {t('settings.plugins.groupHint', { count: String(items.length) })} +

+
+ {items.length} +
+
+ {items.map((plugin) => ( + + ))} +
+
+ ) +} + +function SummaryCard({ + label, + value, + icon, +}: { + label: string + value: string + icon: string +}) { + return ( +
+
+ {icon} + + {label} + +
+
+ {value} +
+
+ ) +} + +function StatusPill({ plugin }: { plugin: PluginSummary }) { + const t = useTranslation() + + if (plugin.hasErrors) { + return ( + + {t('settings.plugins.status.attention')} + + ) + } + + return ( + + {plugin.enabled + ? t('settings.plugins.status.enabled') + : t('settings.plugins.status.disabled')} + + ) +} + +function ScopePill({ scope }: { scope: PluginSummary['scope'] }) { + const t = useTranslation() + return ( + + {t(`settings.plugins.scope.${scope}`)} + + ) +} diff --git a/desktop/src/components/shared/ConfirmDialog.tsx b/desktop/src/components/shared/ConfirmDialog.tsx new file mode 100644 index 00000000..6284547d --- /dev/null +++ b/desktop/src/components/shared/ConfirmDialog.tsx @@ -0,0 +1,49 @@ +import { Modal } from './Modal' +import { Button } from './Button' + +type ConfirmDialogProps = { + open: boolean + onClose: () => void + onConfirm: () => void | Promise + title: string + body: string + confirmLabel: string + cancelLabel: string + confirmVariant?: 'primary' | 'danger' + loading?: boolean +} + +export function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + body, + confirmLabel, + cancelLabel, + confirmVariant = 'danger', + loading = false, +}: ConfirmDialogProps) { + return ( + {} : onClose} + title={title} + width={460} + footer={( + <> + + + + )} + > +

+ {body} +

+
+ ) +} diff --git a/desktop/src/components/shared/Dropdown.tsx b/desktop/src/components/shared/Dropdown.tsx index fbbaf212..13f3f5bb 100644 --- a/desktop/src/components/shared/Dropdown.tsx +++ b/desktop/src/components/shared/Dropdown.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, type ReactNode } from 'react' +import { useState, useRef, useEffect, type CSSProperties, type ReactNode } from 'react' type DropdownItem = { value: T @@ -12,8 +12,9 @@ type DropdownProps = { value: T onChange: (value: T) => void trigger: ReactNode - width?: number + width?: CSSProperties['width'] align?: 'left' | 'right' + className?: string } export function Dropdown({ @@ -23,6 +24,7 @@ export function Dropdown({ trigger, width = 320, align = 'left', + className = '', }: DropdownProps) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -46,7 +48,7 @@ export function Dropdown({ }, [open]) return ( -
+
setOpen(!open)} className="cursor-pointer"> {trigger}
@@ -54,8 +56,8 @@ export function Dropdown({ {open && (
({ key={item.value} onClick={() => { onChange(item.value); setOpen(false) }} className={` - w-full flex items-center gap-3 px-4 py-3 text-left transition-colors - hover:bg-[var(--color-surface-hover)] + w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors + hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] + ${item.value === value ? 'bg-[var(--color-model-option-selected-bg)]' : ''} ${i > 0 ? 'border-t border-[var(--color-border-separator)]' : ''} `} > - {item.icon && {item.icon}} + {item.icon && {item.icon}}
{item.label}
{item.description && ( @@ -80,7 +83,7 @@ export function Dropdown({ )}
{item.value === value && ( - + check_circle )} ))} diff --git a/desktop/src/components/shared/Modal.test.tsx b/desktop/src/components/shared/Modal.test.tsx new file mode 100644 index 00000000..69958905 --- /dev/null +++ b/desktop/src/components/shared/Modal.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Modal } from './Modal' + +describe('Modal', () => { + it('portals the dialog to body so the scrim covers the full app shell', () => { + const onClose = vi.fn() + const { container } = render( +
+ + Provider form + +
, + ) + + const dialog = screen.getByRole('dialog', { name: 'Provider' }) + + expect(container.contains(dialog)).toBe(false) + expect(document.body.contains(dialog)).toBe(true) + }) + + it('closes when the backdrop is clicked', () => { + const onClose = vi.fn() + render( + + Provider form + , + ) + + const backdrop = screen.getByRole('dialog').previousElementSibling + expect(backdrop).not.toBeNull() + fireEvent.click(backdrop!) + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/src/components/shared/Modal.tsx b/desktop/src/components/shared/Modal.tsx index c1f5b278..adaa82c5 100644 --- a/desktop/src/components/shared/Modal.tsx +++ b/desktop/src/components/shared/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, type ReactNode } from 'react' +import { createPortal } from 'react-dom' type ModalProps = { open: boolean @@ -21,7 +22,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M if (!open) return null - return ( + return createPortal(
{/* Backdrop */}
{title && (
@@ -60,6 +62,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
)}
-
+
, + document.body, ) } diff --git a/desktop/src/components/shared/ProjectContextChip.tsx b/desktop/src/components/shared/ProjectContextChip.tsx index f6a7fb49..2e798d81 100644 --- a/desktop/src/components/shared/ProjectContextChip.tsx +++ b/desktop/src/components/shared/ProjectContextChip.tsx @@ -2,21 +2,26 @@ type Props = { workDir?: string | null repoName?: string | null branch?: string | null + compact?: boolean } -export function ProjectContextChip({ workDir, repoName, branch }: Props) { +export function ProjectContextChip({ workDir, repoName, branch, compact = false }: Props) { const label = branch ? (repoName || workDir?.split('/').pop() || '') : (workDir?.split('/').pop() || repoName || '') if (!label) return null return ( -
+
{branch ? ( - + ) : ( - folder + folder )} {label} {branch ? ( diff --git a/desktop/src/components/shared/UpdateChecker.test.tsx b/desktop/src/components/shared/UpdateChecker.test.tsx new file mode 100644 index 00000000..e29e2785 --- /dev/null +++ b/desktop/src/components/shared/UpdateChecker.test.tsx @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { UpdateChecker } from './UpdateChecker' +import { useSettingsStore } from '../../stores/settingsStore' +import { useUpdateStore } from '../../stores/updateStore' + +describe('UpdateChecker', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + Object.defineProperty(window, '__TAURI__', { + value: {}, + configurable: true, + }) + + useUpdateStore.setState({ + status: 'available', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5\n\n[Release notes](https://example.com/releases/v0.1.5)', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + }) + + it('renders markdown release notes in the update prompt', () => { + render() + + expect(screen.getByText('v0.1.5 available')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Release notes' }) + expect(link).toHaveAttribute('href', 'https://example.com/releases/v0.1.5') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('shows downloaded bytes when the updater does not provide total size', () => { + useUpdateStore.setState({ + status: 'downloading', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5', + progressPercent: 0, + downloadedBytes: 1536, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + + render() + + expect(screen.getByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument() + expect(screen.queryByText(/0%/)).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/shared/UpdateChecker.tsx b/desktop/src/components/shared/UpdateChecker.tsx index 9292cd8a..d9bec6a5 100644 --- a/desktop/src/components/shared/UpdateChecker.tsx +++ b/desktop/src/components/shared/UpdateChecker.tsx @@ -1,7 +1,9 @@ import { useEffect } from 'react' import { useTranslation } from '../../i18n' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { isTauriRuntime } from '../../lib/desktopRuntime' import { useUpdateStore } from '../../stores/updateStore' +import { formatBytes } from '../../lib/formatBytes' export function UpdateChecker() { const t = useTranslation() @@ -9,6 +11,8 @@ export function UpdateChecker() { const availableVersion = useUpdateStore((s) => s.availableVersion) const releaseNotes = useUpdateStore((s) => s.releaseNotes) const progressPercent = useUpdateStore((s) => s.progressPercent) + const downloadedBytes = useUpdateStore((s) => s.downloadedBytes) + const totalBytes = useUpdateStore((s) => s.totalBytes) const error = useUpdateStore((s) => s.error) const shouldPrompt = useUpdateStore((s) => s.shouldPrompt) const initialize = useUpdateStore((s) => s.initialize) @@ -26,11 +30,15 @@ export function UpdateChecker() { if (!showPopup) return null + const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0 + const downloadedText = formatBytes(downloadedBytes) const statusText = status === 'restarting' ? t('update.restarting') : status === 'downloading' - ? t('update.downloading') + ? hasKnownProgress + ? t('update.downloading') + : t('update.progressBytes', { downloaded: downloadedText }) : null return ( @@ -41,22 +49,30 @@ export function UpdateChecker() {

{releaseNotes && ( -

- {releaseNotes} -

+
+ +
)} {(status === 'downloading' || status === 'restarting') && (
-
+ {hasKnownProgress || status === 'restarting' ? ( +
+ ) : ( +
+ )}
{statusText && (

- {statusText} {status === 'downloading' ? `${progressPercent}%` : ''} + {statusText} + {status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}

)}
diff --git a/desktop/src/components/skills/SkillDetail.tsx b/desktop/src/components/skills/SkillDetail.tsx index d5b9cec0..7788fe46 100644 --- a/desktop/src/components/skills/SkillDetail.tsx +++ b/desktop/src/components/skills/SkillDetail.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { CodeViewer } from '../chat/CodeViewer' import type { FileTreeNode, SkillFrontmatter } from '../../types/skill' +import { useUIStore } from '../../stores/uiStore' const META_PRIORITY = [ 'description', @@ -20,7 +21,7 @@ const META_PRIORITY = [ ] as const export function SkillDetail() { - const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore() + const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection } = useSkillStore() const t = useTranslation() const [selectedFile, setSelectedFile] = useState('SKILL.md') @@ -31,6 +32,14 @@ export function SkillDetail() { : selectedSkill.files[0]?.path || 'SKILL.md' }, [selectedFile, selectedSkill]) + const handleBack = () => { + const returnTab = selectedSkillReturnTab + clearSelection() + if (returnTab === 'plugins') { + useUIStore.getState().setPendingSettingsTab('plugins') + } + } + if (isDetailLoading) { return (
@@ -50,7 +59,7 @@ export function SkillDetail() {
+
+ )} +
+
+ ) +} diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx new file mode 100644 index 00000000..20f226cb --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -0,0 +1,1224 @@ +// @vitest-environment jsdom + +// @ts-expect-error jsdom is installed in this workspace without local type declarations +import { JSDOM } from 'jsdom' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +if (typeof document === 'undefined') { + const dom = new JSDOM('', { + url: 'http://localhost/', + }) + const { window } = dom + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + localStorage: window.localStorage, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + MutationObserver: window.MutationObserver, + Node: window.Node, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + getComputedStyle: window.getComputedStyle.bind(window), + IS_REACT_ACT_ENVIRONMENT: true, + }) +} + +type WorkspaceApiMocks = { + getWorkspaceStatusMock: ReturnType + getWorkspaceTreeMock: ReturnType + getWorkspaceFileMock: ReturnType + getWorkspaceDiffMock: ReturnType +} + +var mocks: WorkspaceApiMocks | undefined + +function getMocks() { + if (!mocks) { + throw new Error('Workspace API mocks were not initialized') + } + return mocks +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +async function setWorkspaceState( + updater: + | ReturnType + | Parameters[0], +) { + await act(() => { + useWorkspacePanelStore.setState(updater as Parameters[0], true) + }) +} + +async function setSettingsState( + updater: + | ReturnType + | Parameters[0], +) { + await act(() => { + useSettingsStore.setState(updater as Parameters[0], true) + }) +} + +async function renderPanel(sessionId: string) { + let view!: ReturnType + await act(() => { + view = render() + }) + return view +} + +async function clickElement(element: Element) { + await act(() => { + fireEvent.click(element) + }) +} + +function classNameContains(element: Element | null, needle: string) { + let current = element + while (current) { + if (typeof current.className === 'string' && current.className.includes(needle)) { + return true + } + current = current.parentElement + } + return false +} + +vi.mock('../../api/sessions', () => ({ + sessionsApi: (() => { + if (!mocks) { + mocks = { + getWorkspaceStatusMock: vi.fn(), + getWorkspaceTreeMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), + getWorkspaceDiffMock: vi.fn(), + } + } + + return { + getWorkspaceStatus: mocks.getWorkspaceStatusMock, + getWorkspaceTree: mocks.getWorkspaceTreeMock, + getWorkspaceFile: mocks.getWorkspaceFileMock, + getWorkspaceDiff: mocks.getWorkspaceDiffMock, + } + })(), +})) + +import { useSettingsStore } from '../../stores/settingsStore' +import { useChatStore } from '../../stores/chatStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { WorkspacePanel } from './WorkspacePanel' + +describe('WorkspacePanel', () => { + const workspaceInitialState = useWorkspacePanelStore.getInitialState() + const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState() + const settingsInitialState = useSettingsStore.getInitialState() + const chatInitialState = useChatStore.getInitialState() + + beforeEach(async () => { + vi.clearAllMocks() + await setWorkspaceState(workspaceInitialState) + useChatStore.setState(chatInitialState, true) + useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) + await setSettingsState({ ...settingsInitialState, locale: 'en' }) + + getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => + useWorkspacePanelStore.getState().statusBySession[sessionId] ?? { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + ) + getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') => + useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? { + state: 'ok', + path, + entries: [], + }, + ) + }) + + afterEach(async () => { + cleanup() + await setWorkspaceState(workspaceInitialState) + useChatStore.setState(chatInitialState, true) + useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) + await setSettingsState(settingsInitialState) + vi.restoreAllMocks() + }) + + it('stays hidden when the panel is closed', async () => { + const view = await renderPanel('session-hidden') + + expect(view.queryByTestId('workspace-panel')).toBeNull() + }) + + it('loads changed status on open and opens a diff preview from the changed view', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: Array<{ + path: string + status: 'modified' + additions: number + deletions: number + }> + }>() + const diffRequest = deferred<{ + state: 'ok' + path: string + diff: string + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-changed') + }) + + const view = await renderPanel('session-changed') + + const compactPanel = view.getByTestId('workspace-panel') + expect(compactPanel.style.width).toBe('520px') + expect(compactPanel.style.maxWidth).toBe('36%') + expect(compactPanel.style.minWidth).toBe('min(340px, 40%)') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed') + }) + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/app.ts', + status: 'modified', + additions: 4, + deletions: 1, + }, + ], + }) + await statusRequest.promise + }) + + expect(view.getByPlaceholderText('Filter files...')).toBeTruthy() + + await clickElement(await view.findByText('src/app.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts') + }) + + await act(async () => { + diffRequest.resolve({ + state: 'ok', + path: 'src/app.ts', + diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")', + }) + await diffRequest.promise + }) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")') + }) + const expandedPanel = view.getByTestId('workspace-panel') + expect(expandedPanel.style.width).toBe('860px') + expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))') + expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)') + expect(view.getAllByText('Diff').length).toBeGreaterThan(0) + }) + + it('refreshes status on open and switches back to changed files when new changes exist', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/Fresh.ts', + status: 'modified', + additions: 4, + deletions: 1, + }, + ], + }) + getMocks().getWorkspaceTreeMock.mockResolvedValue({ + state: 'ok', + path: '', + entries: [{ name: 'src', path: 'src', isDirectory: true }], + }) + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-stale-all': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-stale-all': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-stale-all') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all') + }) + await waitFor(() => { + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + }) + expect(view.getByText('src/Fresh.ts')).toBeTruthy() + }) + + it('loads workspace status when opened while the chat is running', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/running.ts', + status: 'modified', + additions: 1, + deletions: 0, + }, + ], + }) + + useChatStore.setState({ + sessions: { + 'session-running-open': { + chatState: 'thinking', + } as never, + }, + }) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-running-open') + }) + + const view = await renderPanel('session-running-open') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open') + }) + await waitFor(() => { + expect(view.getByText('src/running.ts')).toBeTruthy() + }) + expect(view.queryByText('Loading...')).toBeNull() + }) + + it('renders transcript-derived changed files for non-git sessions', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/tmp/non-git-session', + repoName: 'non-git-session', + branch: null, + isGitRepo: false, + changedFiles: [ + { + path: 'src/app.ts', + status: 'modified', + additions: 1, + deletions: 1, + }, + ], + }) + getMocks().getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/app.ts', + diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2', + }) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-non-git') + }) + + const view = await renderPanel('session-non-git') + + await waitFor(() => { + expect(view.getByText('src/app.ts')).toBeTruthy() + }) + expect(view.queryByText('No matching files')).toBeNull() + + await clickElement(view.getByText('src/app.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts') + }) + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2') + }) + }) + + it('opens to all files when the current turn has no changed files', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: [] + }>() + const rootTreeRequest = deferred<{ + state: 'ok' + path: '' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-empty-tree') + }) + + const view = await renderPanel('session-empty-tree') + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }) + await statusRequest.promise + }) + + await waitFor(() => { + expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all') + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '') + }) + + await act(async () => { + rootTreeRequest.resolve({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + await rootTreeRequest.promise + }) + + expect(view.getByRole('button', { name: 'All files' })).toBeTruthy() + expect(await view.findByText('src')).toBeTruthy() + expect(await view.findByText('README.md')).toBeTruthy() + expect(view.queryByText('No changes')).toBeNull() + }) + + it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: [] + }>() + const rootTreeRequest = deferred<{ + state: 'ok' + path: '' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + const childTreeRequest = deferred<{ + state: 'ok' + path: 'src' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + const fileRequest = deferred<{ + state: 'ok' + path: string + content: string + language: string + size: number + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceTreeMock + .mockReturnValueOnce(rootTreeRequest.promise) + .mockReturnValueOnce(childTreeRequest.promise) + getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-tree') + }) + + const view = await renderPanel('session-tree') + + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + + await clickElement(view.getByRole('button', { name: 'Changed files' })) + await clickElement(view.getByRole('menuitem', { name: 'All files' })) + + await waitFor(() => { + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '') + }) + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }) + rootTreeRequest.resolve({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + await Promise.all([statusRequest.promise, rootTreeRequest.promise]) + }) + + const folderLabel = await view.findByText('src') + const folderButton = folderLabel.closest('button') + if (!folderButton) { + throw new Error('Expected src label to be rendered inside a folder button') + } + expect(folderButton.getAttribute('aria-expanded')).toBe('false') + + await clickElement(folderButton) + + await waitFor(() => { + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src') + }) + await act(async () => { + childTreeRequest.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }], + }) + await childTreeRequest.promise + }) + await waitFor(() => { + expect(folderButton.getAttribute('aria-expanded')).toBe('true') + }) + + await clickElement(await view.findByText('index.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts') + }) + await act(async () => { + fileRequest.resolve({ + state: 'ok', + path: 'src/index.ts', + content: 'export const ready = true', + language: 'typescript', + size: 25, + }) + await fileRequest.promise + }) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true') + }) + expect(view.getAllByText('File').length).toBeGreaterThan(0) + }) + + it('renders multiple preview tabs and closes only the exact requested tab', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-tabs': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-tabs': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-tabs': [ + { + id: 'file:src/a.ts', + path: 'src/a.ts', + kind: 'file', + title: 'a.ts', + language: 'typescript', + content: 'export const a = 1', + state: 'ok', + size: 18, + }, + { + id: 'diff:src/a.ts', + path: 'src/a.ts', + kind: 'diff', + title: 'a.ts', + diff: '@@ -1 +1 @@', + state: 'ok', + }, + { + id: 'file:src/b.ts', + path: 'src/b.ts', + kind: 'file', + title: 'b.ts', + language: 'typescript', + content: 'export const b = 1', + state: 'ok', + size: 18, + }, + ], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-tabs': 'diff:src/a.ts', + }, + })) + + const view = await renderPanel('session-tabs') + + expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy() + expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2) + expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2) + expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) + + await clickElement(view.getByLabelText('Close tab a.ts Diff')) + + expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull() + expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy() + expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) + }) + + it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => { + await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' }) + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-dark-theme': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-dark-theme': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-dark-theme': [{ + id: 'file:src/theme.ts', + path: 'src/theme.ts', + kind: 'file', + title: 'theme.ts', + language: 'typescript', + content: 'export const theme = "dark"', + state: 'ok', + size: 27, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-dark-theme': 'file:src/theme.ts', + }, + })) + + const view = await renderPanel('session-dark-theme') + const panel = view.getByTestId('workspace-panel') + const tabList = view.getByRole('tablist', { name: 'Preview tabs' }) + const codeSurface = view.getByTestId('workspace-code') + + expect(panel.className).toContain('bg-[var(--color-surface)]') + expect(panel.className).not.toContain('bg-white') + expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]') + expect(tabList.className).not.toContain('bg-white') + expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true) + expect(classNameContains(codeSurface, 'bg-white')).toBe(false) + }) + + it('can expand long diff previews beyond the default rendered line cap', async () => { + const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n') + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-large-preview': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-large-preview': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-large-preview': [{ + id: 'diff:large.ts', + path: 'large.ts', + kind: 'diff', + title: 'large.ts', + diff: longDiff, + state: 'ok', + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-large-preview': 'diff:large.ts', + }, + })) + + const view = await renderPanel('session-large-preview') + const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' + + expect(highlightedCode).toContain('+line 1') + expect(highlightedCode).toContain('+line 2000') + expect(highlightedCode).not.toContain('+line 2001') + await clickElement(view.getByRole('button', { name: 'Show all loaded lines' })) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300') + }) + expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() + }) + + it('can expand long file previews beyond the default rendered line cap', async () => { + const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n') + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-large-file-preview': { + isOpen: true, + activeView: 'all', + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-large-file-preview': [{ + id: 'file:large-file.ts', + path: 'large-file.ts', + kind: 'file', + title: 'large-file.ts', + content: longFile, + language: 'typescript', + previewType: 'text', + state: 'ok', + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-large-file-preview': 'file:large-file.ts', + }, + })) + + const view = await renderPanel('session-large-file-preview') + const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' + + expect(highlightedCode).toContain('const line1 = 1') + expect(highlightedCode).toContain('const line2000 = 2000') + expect(highlightedCode).not.toContain('const line2001 = 2001') + await clickElement(view.getByRole('button', { name: 'Show all loaded lines' })) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300') + }) + expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() + }) + + it('renders image previews from workspace files', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-image-preview': { + isOpen: true, + activeView: 'all', + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-image-preview': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }], + }, + }, + }, + })) + + getMocks().getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'logo.png', + previewType: 'image', + dataUrl: 'data:image/png;base64,iVBORw0KGgo=', + mimeType: 'image/png', + language: 'image', + size: 8, + }) + + const view = await renderPanel('session-image-preview') + + await clickElement(await view.findByText('logo.png')) + + const image = await view.findByRole('img', { name: 'logo.png' }) + expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=') + }) + + it('renders markdown file previews as formatted documents', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-markdown-preview': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-markdown-preview': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-markdown-preview': [{ + id: 'file:README.md', + path: 'README.md', + kind: 'file', + title: 'README.md', + language: 'markdown', + content: '# Project Notes\n\n- **Done** item\n\n```ts\nexport const ok = true\n```', + state: 'ok', + size: 70, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-markdown-preview': 'file:README.md', + }, + })) + + const view = await renderPanel('session-markdown-preview') + + expect(view.getByRole('heading', { name: 'Project Notes', level: 1 })).toBeTruthy() + expect(view.getByText('Done')).toBeTruthy() + expect(view.container.textContent).toContain('export const ok = true') + expect(view.queryByTestId('workspace-code')).toBeNull() + }) + + it('opens a context menu for preview tabs and closes tabs to the right', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-preview-menu': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-preview-menu': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-preview-menu': [ + { + id: 'file:src/App.jsx', + path: 'src/App.jsx', + kind: 'file', + title: 'App.jsx', + language: 'jsx', + content: 'app', + state: 'ok', + size: 3, + }, + { + id: 'diff:vite.config.js', + path: 'vite.config.js', + kind: 'diff', + title: 'vite.config.js', + diff: '@@ -1 +1 @@', + state: 'ok', + size: 12, + }, + { + id: 'file:src/index.css', + path: 'src/index.css', + kind: 'file', + title: 'index.css', + language: 'css', + content: 'body{}', + state: 'ok', + size: 6, + }, + ], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-preview-menu': 'diff:vite.config.js', + }, + })) + + const view = await renderPanel('session-preview-menu') + + await act(() => { + fireEvent.contextMenu(view.getByRole('tab', { name: /vite\.config\.js/i }), { + clientX: 320, + clientY: 42, + }) + }) + + await clickElement(view.getByRole('menuitem', { name: 'Close to the Right' })) + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-menu']).toMatchObject([ + { id: 'file:src/App.jsx' }, + { id: 'diff:vite.config.js' }, + ]) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-menu']).toBe('diff:vite.config.js') + expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull() + }) + + it('adds a workspace file to the chat context from the file tree menu', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-add-file': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-add-file': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-add-file': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }], + }, + }, + }, + })) + + const view = await renderPanel('session-add-file') + + await act(() => { + fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), { + clientX: 260, + clientY: 80, + }) + }) + + await clickElement(view.getByRole('menuitem', { name: 'Add to chat' })) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-file']).toMatchObject([ + { + kind: 'file', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + }, + ]) + }) + + it('adds a line comment from a code preview to the chat context', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-line-comment': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-line-comment': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-line-comment': [{ + id: 'file:src/App.tsx', + path: 'src/App.tsx', + kind: 'file', + title: 'App.tsx', + language: 'tsx', + content: 'const title = "Todo"\nexport default title', + state: 'ok', + size: 42, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-line-comment': 'file:src/App.tsx', + }, + })) + + const view = await renderPanel('session-line-comment') + + await clickElement(view.getByRole('button', { name: 'Comment line 1' })) + const textarea = view.getByPlaceholderText('Describe what should change here...') + await act(() => { + fireEvent.change(textarea, { target: { value: 'Rename this title' } }) + }) + await clickElement(view.getByRole('button', { name: 'Add comment' })) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-comment']).toMatchObject([ + { + kind: 'code-comment', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + lineStart: 1, + lineEnd: 1, + note: 'Rename this title', + quote: 'const title = "Todo"', + }, + ]) + }) + + it('uses the localized view menu label', async () => { + await setSettingsState({ ...settingsInitialState, locale: 'zh' }) + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-zh': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-zh': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-zh') + + expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy() + }) + + it('keeps the workspace header controls compact', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-compact-header': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-compact-header': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-compact-header') + const viewMenuButton = view.getByRole('button', { name: 'Changed files' }) + const refreshButton = view.getByRole('button', { name: 'Refresh workspace' }) + const closeButton = view.getByRole('button', { name: 'Close workspace panel' }) + + expect(viewMenuButton.className).toContain('text-[14px]') + expect(viewMenuButton.className).not.toContain('text-[18px]') + expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]') + expect(refreshButton.className).toContain('h-7 w-7') + expect(closeButton.className).toContain('h-7 w-7') + expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]') + expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]') + }) + + it('shows explicit empty and error states in the changed view', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-empty': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-empty': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-empty') + + expect(view.getByText('No changes')).toBeTruthy() + + getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => { + if (sessionId === 'session-error') { + throw new Error('status failed') + } + return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + } + }) + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-error': { + isOpen: true, + activeView: 'changed', + hasUserSelectedView: true, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + 'session-error': 'status failed', + }, + }, + })) + + await act(() => { + view.rerender() + }) + + await waitFor(() => { + expect(view.getByText('status failed')).toBeTruthy() + }) + }) +}) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx new file mode 100644 index 00000000..333061be --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -0,0 +1,1244 @@ +import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' +import { Highlight } from 'prism-react-renderer' +import type { + WorkspaceChangedFile, + WorkspaceFileStatus, + WorkspaceTreeEntry, + WorkspaceTreeResult, +} from '../../api/sessions' +import { useTranslation } from '../../i18n' +import { useShallow } from 'zustand/react/shallow' +import { + useWorkspacePanelStore, + type WorkspacePreviewCloseScope, + type WorkspacePreviewKind, + type WorkspacePreviewTab, +} from '../../stores/workspacePanelStore' +import { useChatStore } from '../../stores/chatStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' +import { + getFileExtension, + normalizePrismLanguage, + WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD, + WORKSPACE_PREVIEW_LINE_LIMIT, + WorkspaceDiffSurface, + workspacePrismTheme, +} from './WorkspaceCodeSurface' + +type WorkspacePanelProps = { + sessionId: string +} + +type TreeNodeProps = { + sessionId: string + entry: WorkspaceTreeEntry + depth: number + expandedPaths: Set + treeByPath: Record + treeLoadingByPath: Record + treeErrorsByPath: Record + filterQuery: string + onToggle: (path: string) => void + onOpenFile: (path: string) => void + onFileContextMenu: (event: MouseEvent, path: string) => void + activePath: string | null +} + +const FILE_STATUS_META: Record = { + modified: { + label: 'M', + className: 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/12 text-[var(--color-warning)]', + }, + added: { + label: 'A', + className: 'border-[var(--color-success)]/35 bg-[var(--color-success)]/12 text-[var(--color-success)]', + }, + deleted: { + label: 'D', + className: 'border-[var(--color-error)]/35 bg-[var(--color-error)]/12 text-[var(--color-error)]', + }, + renamed: { + label: 'R', + className: 'border-[var(--color-brand)]/35 bg-[var(--color-brand)]/12 text-[var(--color-brand)]', + }, + untracked: { + label: 'U', + className: 'border-[var(--color-tertiary)]/35 bg-[var(--color-tertiary)]/12 text-[var(--color-tertiary)]', + }, + copied: { + label: 'C', + className: 'border-[var(--color-secondary)]/35 bg-[var(--color-secondary)]/12 text-[var(--color-secondary)]', + }, + type_changed: { + label: 'T', + className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', + }, + unknown: { + label: '?', + className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', + }, +} + +const EMPTY_TREE_BY_PATH: Record = {} +const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] +const EMPTY_EXPANDED_PATHS: string[] = [] +const FILE_BADGE_META: Record = { + ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, + tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, + js: { label: 'JS', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' }, + jsx: { label: 'JSX', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' }, + json: { label: '{}', className: 'bg-[var(--color-tertiary)]/14 text-[var(--color-tertiary)]' }, + md: { label: 'MD', className: 'bg-[var(--color-text-tertiary)]/14 text-[var(--color-text-secondary)]' }, + css: { label: 'CSS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, + html: { label: 'H', className: 'bg-[var(--color-brand)]/14 text-[var(--color-brand)]' }, + png: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + jpg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + jpeg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + gif: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + svg: { label: 'SVG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, +} + +function makeTreeStateKey(sessionId: string, path: string) { + return `${sessionId}::${path}` +} + +function makePreviewStateKey(sessionId: string, tabId: string) { + return `${sessionId}::${tabId}` +} + +function getSessionScopedRecord( + record: Record, + sessionId: string, +) { + const prefix = `${sessionId}::` + return Object.fromEntries( + Object.entries(record).filter(([key]) => key.startsWith(prefix)), + ) as Record +} + +function getPreviewKindLabel( + t: ReturnType, + kind: WorkspacePreviewKind, +) { + return kind === 'diff' ? t('workspace.previewKind.diff') : t('workspace.previewKind.file') +} + +function getFileBadgeMeta(name: string) { + const extension = getFileExtension(name) + return FILE_BADGE_META[extension] ?? { + label: extension ? extension.slice(0, 3).toUpperCase() : 'TXT', + className: 'bg-[var(--color-text-tertiary)]/12 text-[var(--color-text-secondary)]', + } +} + +function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: string) { + if (!workDir || filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) return filePath + return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}` +} + +function isMarkdownPreview(tab: WorkspacePreviewTab) { + if (tab.kind !== 'file') return false + const language = (tab.language ?? '').toLowerCase() + const extension = getFileExtension(tab.path) + return language === 'markdown' || language === 'md' || extension === 'md' || extension === 'markdown' +} + +function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) { + const meta = getFileBadgeMeta(name) + return ( + + ) +} + +function getInlineStateMessage( + t: ReturnType, + state: WorkspacePreviewTab['state'] | WorkspaceTreeResult['state'] | 'not_git_repo' | undefined, + fallbackError?: string | null, +) { + switch (state) { + case 'loading': + return t('workspace.previewState.loading') + case 'binary': + return t('workspace.previewState.binary') + case 'too_large': + return t('workspace.previewState.tooLarge') + case 'missing': + return t('workspace.previewState.missing') + case 'not_git_repo': + return t('workspace.notGitRepo') + case 'error': + return fallbackError || t('workspace.loadError') + default: + return fallbackError || t('workspace.loadError') + } +} + +function normalizeFilterQuery(query: string) { + return query.trim().toLowerCase() +} + +function changedFileMatchesFilter(file: WorkspaceChangedFile, query: string) { + if (!query) return true + return ( + file.path.toLowerCase().includes(query) + || file.oldPath?.toLowerCase().includes(query) + || file.status.toLowerCase().includes(query) + ) +} + +function treeEntryMatchesFilter( + entry: WorkspaceTreeEntry, + query: string, + treeByPath: Record, +): boolean { + if (!query) return true + if (entry.name.toLowerCase().includes(query) || entry.path.toLowerCase().includes(query)) { + return true + } + + if (!entry.isDirectory) return false + const childTree = treeByPath[entry.path] + if (childTree?.state !== 'ok') return false + return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath)) +} + +function PanelMessage({ + icon, + message, + tone = 'muted', + compact = false, +}: { + icon: string + message: string + tone?: 'muted' | 'error' + compact?: boolean +}) { + const toneClass = + tone === 'error' + ? 'text-[var(--color-error)]' + : 'text-[var(--color-text-tertiary)]' + + return ( +
+ + {icon} + + {message} +
+ ) +} + +function ToolbarIconButton({ + icon, + label, + onClick, +}: { + icon: string + label: string + onClick: () => void +}) { + return ( + + ) +} + +function WorkspaceFilterInput({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}) { + const t = useTranslation() + + return ( +
+ +
+ ) +} + +function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) { + const meta = FILE_STATUS_META[status] + return ( + + {meta.label} + + ) +} + +function CodeSurface({ + value, + language, + onAddLineComment, +}: { + value: string + language: string + onAddLineComment: (line: number, note: string, quote: string) => void +}) { + const t = useTranslation() + const [commentLine, setCommentLine] = useState(null) + const [commentDraft, setCommentDraft] = useState('') + const [showAllLines, setShowAllLines] = useState(false) + const lines = value.split('\n') + const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT) + const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : '' + const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n') + + useEffect(() => { + setShowAllLines(false) + setCommentLine(null) + setCommentDraft('') + }, [language, value]) + + const submitLineComment = () => { + if (!commentLine || !commentDraft.trim()) return + onAddLineComment(commentLine, commentDraft.trim(), activeQuote) + setCommentLine(null) + setCommentDraft('') + } + + const renderLineCommentEditor = (lineNumber: number) => { + if (commentLine !== lineNumber) return null + + return ( +
+