Merge latest main into Azure OpenAI PR

Resolve the remaining conflict in the Claude API request builder by preserving the contributor's Azure OpenAI response support while keeping main's explicit disabled-thinking handling and effort suppression.

Constraint: PR #21 was conflict-blocked against origin/main and maintainer edits are enabled on the fork branch

Rejected: Rebase the contributor branch | would rewrite a long external contribution history unnecessarily

Confidence: high

Scope-risk: moderate

Directive: Keep disabled-thinking effort suppression when changing provider request mapping

Tested: bun test tests/azureOpenAI.test.ts src/utils/__tests__/thinking.test.ts

Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-02 23:48:42 +08:00
commit 53f2be67f8
359 changed files with 41132 additions and 3261 deletions

View File

@ -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: ''
---
## 🔍 问题检查清单
<!-- 请在提交 issue 前确认以下事项 -->
## 提交前确认
<!-- 请在提交 issue 前确认以下事项 -->
- [ ] 我已经仔细阅读了 [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-3 句话说明实际发生了什么、你原本期望发生什么 -->
## 📝 复现步骤
1.
2.
3.
## 运行环境
<!-- 桌面端问题请优先填写前 4 项CLI 问题再补充 Bun / Node 信息 -->
## 💻 运行环境
- 操作系统:
- 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 Key、Token 等敏感信息 -->
- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama:
- Base URL:
- 使用的模型:
- 相关配置截图:
## 复现步骤
<!-- 请写到别人可以照着操作复现;如果偶发,请说明大约出现频率 -->
1.
2.
3.
是否稳定复现: 是 / 否 / 不确定
## 错误信息与日志
<!--
请粘贴完整错误信息。桌面端启动失败时,请复制错误弹窗中的 Recent server logs
如果是对话、配置或模型调用失败,请粘贴页面上的错误、终端输出或相关日志。
-->
```shell
在此粘贴错误信息和日志
```
## 📷 错误截图
<!-- 如有,请提供错误截图 -->
## 截图或录屏
<!-- 请提供错误截图、配置截图或复现录屏;截图前请隐藏敏感信息 -->
## 补充信息
<!-- 最近是否升级过版本、换过 provider、改过配置或是否有旧版本可以正常使用 -->
---
<!-- 🤖 Dosu 机器人将自动响应此 issue -->

View File

@ -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
在此粘贴完整的错误日志
## 使用环境
<!-- 桌面端问题请优先填写前 4 项CLI 问题再补充 Bun / Node 信息 -->
- 使用方式: 桌面端 / 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:
- 使用的模型:
## 配置截图
<!-- 请尽量提供 Provider / 模型 / Base URL 等相关配置截图;务必隐藏 API Key、Token 等敏感信息 -->
## 操作步骤
<!-- 请说明你是怎么操作到这个问题的 -->
1.
2.
3.
## 错误信息与日志
<!-- 如果页面、弹窗或终端里有错误,请粘贴完整内容 -->
```shell
在此粘贴错误信息和日志
```
## 📷 错误截图
<!-- 如有,请提供错误截图 -->
## 截图或录屏
<!-- 如有,请提供截图或录屏;截图前请隐藏敏感信息 -->
---
<!-- 🤖 Dosu 机器人将自动响应此 issue -->

13
.github/pull_request_template.md vendored Normal file
View File

@ -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.

View File

@ -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

181
.github/workflows/pr-quality.yml vendored Normal file
View File

@ -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

259
.github/workflows/pr-triage.yml vendored Normal file
View File

@ -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 = '<!-- pr-quality-triage -->'
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,
})
}

View File

@ -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

12
.gitignore vendored
View File

@ -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__
runtime/__pycache__
# IDE
.idea
# Codex logs
.codex-logs/

View File

@ -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 <provider:model[:label]>`: run live Coding Agent baseline cases, including desktop agent-browser smoke.
- `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`: 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 <provider:model[:label]>`.
- For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` 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/<timestamp>/`. 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.

31
CONTRIBUTING.md Normal file
View File

@ -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 <selector>:main
```
Quality reports are written under `artifacts/quality-runs/<timestamp>/`.

View File

@ -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.
<table>
<thead>
<tr>
<th width="220">Sponsor</th>
<th align="left">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="JieKou AI"><br>
<strong>接口AI</strong>
</a>
</td>
<td valign="middle">
Thanks to <a href="https://jiekou.ai/referral?invited_code=OBNU3K">JieKou AI</a> 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 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">this link</a> and bind GitHub can claim a $3 coupon.
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="ShengSuanYun">
</a>
</td>
<td valign="middle">
Thanks to <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">ShengSuanYun</a> 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 <a href="https://watch.shengsuanyun.com/status/shengsuanyun">service status</a> 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 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">this link</a> can receive 10 yuan in model credits plus a 10% first top-up bonus.
</td>
</tr>
</tbody>
</table>
📧 **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.
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
</picture>
</a>
---
## 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.

View File

@ -1,7 +1,7 @@
# Claude Code Haha
<p align="center">
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
<img src="docs/images/app-icon.png" alt="Claude Code Haha" width="240">
</p>
<div align="center">
@ -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
本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。
<table>
<thead>
<tr>
<th width="220">赞助商</th>
<th align="left">介绍</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="接口AI"><br>
<strong>接口AI</strong>
</a>
</td>
<td valign="middle">
感谢 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">接口AI</a> 赞助本项目接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">专属链接</a> 注册并绑定 GitHub可领取 3 美元优惠券。
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="胜算云">
</a>
</td>
<td valign="middle">
感谢 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">胜算云</a> 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 <a href="https://watch.shengsuanyun.com/status/shengsuanyun">服务状态</a>。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK按量与 tokens plan即将上线计费并可开票使用 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">专属链接</a> 注册可获 10 元模力及首充 10% 赠送。
</td>
</tr>
</tbody>
</table>
📧 **联系邮箱**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
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
</picture>
</a>
---
## Disclaimer
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。

View File

@ -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<typeof setTimeout>
} {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
return { controller, timer }
}
async createSession(workDir: string): Promise<string> {
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<RecentProject[]> {
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<GitInfo> {
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<SessionTask[]> {
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 : []
}
}

View File

@ -58,9 +58,9 @@ function readConfigFile(): Record<string, any> {
function writeConfigFile(data: Record<string, any>): 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)
}

View File

@ -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
}
}

View File

@ -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/` 目录,文件名会显式包含平台、架构和包类型
## 常见问题

View File

@ -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=="],

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

BIN
desktop/public/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,17 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
</defs>
<g>
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View File

@ -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 <bundle-id>` 来统一
# 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}" \

View File

@ -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}`)
}

View File

@ -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 {

View File

@ -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 <<NODE
import { readFileSync } from 'node:fs'
const captures = JSON.parse(readFileSync('${ARTIFACT_DIR}/captures.json', 'utf8'))
const main = captures.find((capture) => 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 <<NODE
import { readFileSync } from 'node:fs'
const captures = JSON.parse(readFileSync('${ARTIFACT_DIR}/captures.json', 'utf8'))
const openai = captures.find((capture) => 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}"

View File

@ -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}"

View File

@ -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}"

View File

@ -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}"

View File

@ -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}"

View File

@ -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<void> {
// / 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 }
}

View File

@ -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'],
})
})
})

View File

@ -0,0 +1,64 @@
import path from 'node:path'
export type SidecarMode = 'server' | 'cli' | 'adapters'
const EXPLICIT_MODES = new Set<SidecarMode>(['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 }
}

View File

@ -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",

View File

@ -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"

View File

@ -0,0 +1,44 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="panel" x1="190" y1="168" x2="842" y2="860" gradientUnits="userSpaceOnUse">
<stop stop-color="#231916"/>
<stop offset="1" stop-color="#18110F"/>
</linearGradient>
<linearGradient id="panelGlow" x1="256" y1="172" x2="744" y2="852" gradientUnits="userSpaceOnUse">
<stop stop-color="#7A3B20"/>
<stop offset="1" stop-color="#2B1813"/>
</linearGradient>
<radialGradient id="spark" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(592 402) rotate(90) scale(130)">
<stop stop-color="#FFD56A"/>
<stop offset="1" stop-color="#F6B23E"/>
</radialGradient>
<filter id="sparkGlow" x="470" y="280" width="244" height="244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="18" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0.95 0 1 0 0 0.63 0 0 1 0 0.14 0 0 0 0.7 0"/>
<feBlend in="SourceGraphic"/>
</filter>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
<linearGradient id="sheen" x1="244" y1="224" x2="448" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.22"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<g transform="translate(512 512) scale(1.0612244898) translate(-512 -512)">
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
<path d="M214 760C366 824 566 824 718 754" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="370" cy="292" rx="136" ry="92" fill="url(#sheen)"/>
<path d="M310 350L510 512L310 674" stroke="#F5F0E6" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#F5F0E6"/>
<path d="M684 298H834V726H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#sparkGlow)">
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,13 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="132" fill="#1F1714"/>
<rect x="42" y="42" width="428" height="428" rx="112" fill="url(#surface)"/>
<path d="M352 178C327 153 292 140 252 140C166 140 108 197 108 256C108 314 166 372 252 372C291 372 326 360 351 335L316 298C300 315 278 324 253 324C197 324 159 287 159 256C159 224 197 188 253 188C279 188 301 198 317 215L352 178Z" fill="#FFF7F0"/>
<path d="M381 166L401 146L438 183L418 203L381 166Z" fill="#D07A57"/>
<path d="M369 224L395 198L419 222L393 248L369 224Z" fill="#F0B08E"/>
<defs>
<linearGradient id="surface" x1="66" y1="78" x2="432" y2="436" gradientUnits="userSpaceOnUse">
<stop stop-color="#A85E42"/>
<stop offset="1" stop-color="#7A4330"/>
</linearGradient>
</defs>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
</defs>
<g>
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 855 B

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 B

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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

View File

@ -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(<Settings />)
switchToAgentsTab()
fireEvent.click(screen.getByText('Back to list'))
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
})
})
describe('Settings > Skills tab', () => {

View File

@ -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: () => <div />,
}))
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div />,
}))
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 }) => <pre>{code}</pre>,
}))
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(<Settings />)
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(<Settings />)
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(<Settings />)
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,
})
}
})
})

View File

@ -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: () => <div data-testid="claude-official-login" />,
}))
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
expect(screen.getByTestId('claude-official-login')).toBeInTheDocument()
})
it('requires confirmation before deleting a provider', async () => {
render(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument()
expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument()
})
})

View File

@ -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(<McpSettings />)
expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project')
})
it('renders the empty state and add button', () => {
render(<McpSettings />)
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(<McpSettings />)
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(<McpSettings />)
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(<McpSettings />)
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(<McpSettings />)
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<typeof server>((resolve) => {
resolveReconnect = resolve
}))
useMcpStore.setState({
servers: [server],
reconnectServer,
})
render(<McpSettings />)
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,
})
})
})
})

View File

@ -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<typeof import('../api/sessions')>()
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(<ActiveSession />)
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(<ActiveSession />)
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(<ActiveSession />)
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(<ActiveSession />)
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(<ActiveSession />)
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(<AgentTeams />)
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()
}

View File

@ -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: () => <div>Adapter Settings Mock</div>,
}))
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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()
})
})

View File

@ -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(<Settings />)
@ -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(<Settings />)
switchToSkillsTab()
fireEvent.click(screen.getByText('Back to list'))
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
})
})

View File

@ -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<T>(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<T>(method: string, path: string, body?: unknown, options?
return res.json() as Promise<T>
} catch (err) {
clearTimeout(timeout)
if (controller.signal.aborted) {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
}
throw err
}
}

View File

@ -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<DiagnosticsStatus>('/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'),
}

43
desktop/src/api/mcp.ts Normal file
View File

@ -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 } : {})
},
}

View File

@ -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<PluginListResponse>(`/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 },
)
},
}

Some files were not shown because too many files have changed in this diff Show More