commit 6345505628581c30dc631544f10ba3ef4b800e20 Author: wehub-resource-sync Date: Mon Jul 13 13:00:08 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2478fe0 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill in. Only secrets belong here — a provider's base_url and +# model are configured in reasonix.toml, where api_key_env points at these variables. + +DEEPSEEK_API_KEY= +MIMO_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..53072d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +.gitattributes text eol=lf +.gitignore text eol=lf +.npmrc text eol=lf +*.astro text eol=lf +*.css text eol=lf +*.go text eol=lf +*.html text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.mjs text eol=lf +*.mod text eol=lf +*.nsi text eol=lf +*.plist text eol=lf +*.py text eol=lf +*.rs text eol=lf +*.sh text eol=lf +*.sql text eol=lf +*.sum text eol=lf +*.toml text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.txt text eol=lf +*.xml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +.githooks/** text eol=lf +benchmarks/**/*.sh text eol=lf diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..14ea35e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +# Go-native pre-push gate replacing the retired v1 npm hook (install: make hooks). +# Full tests run in CI; vet is the fast, cross-platform local check. +set -e +go vet ./... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cb04e98 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Every pull request requests review from the maintainers below. +# Either maintainer can review and merge any change. +* @SivanCola @esengine diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..464c873 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,60 @@ +name: Bug report +description: Report a bug in DeepSeek-Reasonix +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. Pick which version line you're on below — it gets + labeled (`v1`/`v2`) automatically, no maintainer action needed. + - type: dropdown + id: version-line + attributes: + label: Version line + description: Which line are you running? + options: + - "v2 — Go rewrite (1.x), main-v2 (active development)" + - "v1 — Legacy TypeScript (0.x), maintenance only" + validations: + required: true + - type: input + id: version + attributes: + label: Exact version + description: Output of `reasonix --version`. + placeholder: e.g. 1.0.0 + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, and what you expected instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: input + id: os + attributes: + label: OS / platform + placeholder: e.g. macOS 15.3 (arm64), Ubuntu 24.04, Windows 11 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or output + description: Paste any error output. Automatically formatted as code. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1dc75ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & discussions + url: https://github.com/esengine/DeepSeek-Reasonix/discussions + about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a1d33f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an idea or improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: dropdown + id: version-line + attributes: + label: Version line + description: Which line is this for? + options: + - "v2 — Go rewrite (1.x), main-v2 (active development)" + - "v1 — Legacy TypeScript (0.x), maintenance only" + validations: + required: true + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How would you like it to work? + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cd9cf45 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + go: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "gomod" + directory: "/desktop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + go: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/desktop/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/site" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: ["*"] diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..9a4a2d6 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,51 @@ +# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml +# (actions/labeler), based on the changed file paths. Keep the area set in sync +# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change, +# not a bug report — and v1/v2 are handled by pr-version-label.yml. + +agent: + - changed-files: + - any-glob-to-any-file: + - 'internal/agent/**' + - 'internal/control/**' + +mcp: + - changed-files: + - any-glob-to-any-file: + - 'internal/plugin/**' + - 'internal/codegraph/**' + +config: + - changed-files: + - any-glob-to-any-file: + - 'internal/config/**' + - 'internal/boot/**' + +provider: + - changed-files: + - any-glob-to-any-file: + - 'internal/provider/**' + +skills: + - changed-files: + - any-glob-to-any-file: + - 'internal/skill/**' + - 'internal/tool/**' + +tui: + - changed-files: + - any-glob-to-any-file: + - 'internal/cli/**' + +desktop: + - changed-files: + - any-glob-to-any-file: + - 'desktop/**' + +updater: + - changed-files: + - any-glob-to-any-file: + - 'desktop/cmd/sign/**' + - 'desktop/updater*.go' + - 'scripts/desktop-build.sh' + - '.github/workflows/release*.yml' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f62dc5f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + +- + +## Verification + +- + +## Cache impact + +Cache-impact: TODO +Cache-guard: TODO +System-prompt-review: N/A + +For cache-sensitive changes, fill these lines before requesting review: + +- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason. +- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change. +- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes. diff --git a/.github/sponsor/wechat-pay.jpg b/.github/sponsor/wechat-pay.jpg new file mode 100644 index 0000000..22906b3 Binary files /dev/null and b/.github/sponsor/wechat-pay.jpg differ diff --git a/.github/workflows/cache-impact.yml b/.github/workflows/cache-impact.yml new file mode 100644 index 0000000..6bae33e --- /dev/null +++ b/.github/workflows/cache-impact.yml @@ -0,0 +1,37 @@ +name: Cache impact guard + +on: + pull_request_target: + branches: [main-v2] + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: cache-impact-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + cache-impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Collect PR changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt" + + - name: Check cache-sensitive PR metadata + env: + PR_BODY: ${{ github.event.pull_request.body }} + CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt + run: bash scripts/check-cache-impact.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1666cdc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,283 @@ +name: CI + +on: + push: + branches: [main-v2] + pull_request: + branches: [main-v2] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every + # file. gofmt output is OS-independent, so the Unix legs already cover it. + - name: gofmt + if: runner.os != 'Windows' + run: | + # Root module only — desktop/ is a separate module with its own tooling. + unformatted=$(gofmt -l . | grep -v '^desktop/' || true) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: vet + run: go vet ./... + + - name: build + run: go build ./... + + - name: test + if: runner.os != 'Windows' + env: + # Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a + # regression there silently tanks the cache hit rate the project is + # built around. + REASONIX_RELEASE_CACHE_GUARD: "1" + run: go test ./... + + - name: test + if: runner.os == 'Windows' + timeout-minutes: 10 + env: + # Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a + # regression there silently tanks the cache hit rate the project is + # built around. + REASONIX_RELEASE_CACHE_GUARD: "1" + # Bound sandbox helper children in Windows tests so a failed OS-level + # launch cannot pin the Actions step after Go's package timeout fires. + WINDOWS_SANDBOX_WAIT_MS: "20000" + run: go test -p 4 -timeout=3m ./... + + race: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # The matrix never runs -race (it needs cgo); the project's concurrency + # (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise + # ship without race coverage. + - name: test -race + env: + REASONIX_RELEASE_CACHE_GUARD: "1" + run: go test -race ./... + + desktop: + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: desktop + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend/pnpm-lock.yaml + + - name: Install Wails CLI + run: | + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: go.mod tidy + run: | + go mod tidy + if ! git diff --quiet -- go.mod go.sum; then + echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit." + git diff -- go.mod go.sum + exit 1 + fi + + # WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag). + - name: Install Linux build deps + run: | + sudo apt-get update + sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev + + - name: Build frontend + run: | + wails generate module + pnpm --dir frontend install --frozen-lockfile + pnpm --dir frontend build + + - name: vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + working-directory: desktop + args: --timeout=5m + + - name: build + run: go build ./... + + - name: test + run: go test ./... + + # Desktop project-root matching is case-insensitive only on Windows + # (sameDesktopPath folds case when os.PathSeparator is '\'), so the + # regression tests for that contract are named *OnWindows and can never + # run on the ubuntu leg above. + desktop-windows: + runs-on: windows-latest + defaults: + run: + shell: bash + working-directory: desktop + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend/pnpm-lock.yaml + + - name: Install Wails CLI + run: | + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + # go:embed of frontend/dist needs a built frontend before the package + # compiles, same as the ubuntu desktop leg. + - name: Build frontend + run: | + wails generate module + pnpm --dir frontend install --frozen-lockfile + pnpm --dir frontend build + + - name: test (Windows-only path semantics) + timeout-minutes: 10 + run: go test . -run 'OnWindows$' -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + args: --timeout=5m + + # The site/ auth client has security-sensitive redirect-validation logic + # (safeNext) covered by node:test unit tests. Those tests use only Node + # builtins, so no `npm install` is needed — run them directly on every PR so + # a regression in redirect validation fails the build instead of shipping. + site: + runs-on: ubuntu-latest + defaults: + run: + working-directory: site + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: test + run: npm test + + govulncheck: + runs-on: ubuntu-latest + continue-on-error: true # informational — stdlib vulns need a Go patch release + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: govulncheck + run: govulncheck ./... + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: test with coverage + run: go test -coverprofile=coverage.out -covermode=atomic ./... + + - name: upload coverage + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: coverage.out + retention-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..2e79b36 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: ["main-v2"] + pull_request: + branches: ["main-v2"] + schedule: + - cron: "27 3 * * 1" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + - language: actions + build-mode: none + steps: + - uses: actions/checkout@v7 + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deploy-accounts-worker.yml b/.github/workflows/deploy-accounts-worker.yml new file mode 100644 index 0000000..afd8d89 --- /dev/null +++ b/.github/workflows/deploy-accounts-worker.yml @@ -0,0 +1,49 @@ +name: Deploy accounts worker + +on: + push: + branches: [main-v2] + paths: + - 'workers/accounts/**' + - '.github/workflows/deploy-accounts-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-accounts-worker + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: workers/accounts/pnpm-lock.yaml + - name: Deploy + working-directory: workers/accounts + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + pnpm install --frozen-lockfile + npx wrangler deploy + - name: Sync RESEND_API_KEY to the worker + working-directory: workers/accounts + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + run: | + if [ -n "$RESEND_API_KEY" ]; then + printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY + else + echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)." + fi diff --git a/.github/workflows/deploy-crash-worker.yml b/.github/workflows/deploy-crash-worker.yml new file mode 100644 index 0000000..992fcd3 --- /dev/null +++ b/.github/workflows/deploy-crash-worker.yml @@ -0,0 +1,34 @@ +name: Deploy crash worker + +on: + push: + branches: [main-v2] + paths: + - 'workers/crash-report/**' + - '.github/workflows/deploy-crash-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-crash-worker + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: workers/crash-report/package-lock.json + - name: Deploy + working-directory: workers/crash-report + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + npm ci + npx wrangler deploy diff --git a/.github/workflows/deploy-forum-worker.yml b/.github/workflows/deploy-forum-worker.yml new file mode 100644 index 0000000..7f2ca2a --- /dev/null +++ b/.github/workflows/deploy-forum-worker.yml @@ -0,0 +1,38 @@ +name: Deploy forum worker + +on: + push: + branches: [main-v2] + paths: + - 'workers/forum/**' + - '.github/workflows/deploy-forum-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-forum-worker + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: workers/forum/pnpm-lock.yaml + - name: Deploy + working-directory: workers/forum + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + pnpm install --frozen-lockfile + npx wrangler deploy diff --git a/.github/workflows/e2e-bot.yml b/.github/workflows/e2e-bot.yml new file mode 100644 index 0000000..1e445b0 --- /dev/null +++ b/.github/workflows/e2e-bot.yml @@ -0,0 +1,160 @@ +name: e2e-bot + +# Comment "/e2e" (fixed suite) or "/e2e diff" (generate tests for the PR's diff) +# on a pull request to run the e2e benchmark against the real provider and post a +# report back. Gated to trusted authors: the job checks out PR-head code and runs +# it with the provider API key, so only the repo owner, members, and collaborators +# may trigger it. + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: e2e-bot-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + e2e: + if: >- + github.event.issue.pull_request && + contains(github.event.comment.body, '/e2e') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + # Running PR-head code with the provider secret is gated twice: the author_ + # association check above, and this deployment environment. Configure the + # `e2e-bot` environment with required reviewers in repo settings to force a + # human approval per run (actions/untrusted-checkout-toctou). + environment: e2e-bot + steps: + - name: Acknowledge + uses: actions/github-script@v9 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: context.payload.comment.id, content: 'eyes', + }); + + # Default-branch checkout: this is where the harness (cmd/e2ebench), the + # suite, and a run --metrics-capable agent live. + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Build harness + fallback agent from the default branch + # Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken + # its own grader or tests. The agent is rebuilt from the PR head below; this + # main-v2 build is only the fallback for heads that predate `run --metrics`. + run: | + go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix + go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench + cp -r benchmarks/e2e "$RUNNER_TEMP/suite" + + - name: Check out the PR head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pin to the head commit resolved now and check it out detached, not the + # mutable PR ref: a force-push mid-run can't swap in different code after + # the trusted-author gate passed. + run: | + SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid) + git fetch -q origin "$SHA" + git checkout -q --detach "$SHA" + + - name: Build the agent from the PR head + # The whole point of the bot is to drive the PR's code, not main-v2's. Fall + # back to the main-v2 build only when the PR head can't yield a + # run --metrics-capable binary (build break or predates the flag). + id: agent + run: | + bin="$RUNNER_TEMP/reasonix-base" + src="main-v2 fallback (PR head lacks run --metrics)" + if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \ + && "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then + bin="$RUNNER_TEMP/reasonix-pr" + src="PR head ($(git rev-parse --short HEAD))" + fi + echo "bin=$bin" >> "$GITHUB_OUTPUT" + echo "src=$src" >> "$GITHUB_OUTPUT" + echo "agent under test: $src" + + - name: Write provider config + # User config covers suite tasks (they run in temp dirs); the repo-root copy + # covers diff mode (the agent runs in the repo root, where project config wins). + run: | + mkdir -p ~/.config/reasonix + cat > /tmp/reasonix-e2e.toml < report.md + exit 0 + fi + if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then + ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1) + [ -z "$ATTEMPTS" ] && ATTEMPTS=1 + [ "$ATTEMPTS" -gt 5 ] && ATTEMPTS=5 + BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName) + git fetch -q origin "$BASE_REF" + BASE=$(git merge-base "origin/$BASE_REF" HEAD) + "$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md + else + "$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000 + fi + + - name: Post report + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TRIGGER_USER: ${{ github.event.comment.user.login }} + run: | + printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md + gh pr comment ${{ github.event.issue.number }} --body-file report.md + + - name: Report failure + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr comment ${{ github.event.issue.number }} --body "🤖 e2e bot failed — see the [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml new file mode 100644 index 0000000..7b1a9c0 --- /dev/null +++ b/.github/workflows/issue-auto-label.yml @@ -0,0 +1,99 @@ +name: Auto-label issues + +# Classify new issues into area / platform / severity labels using the DeepSeek +# API (the project's own model). The model is constrained to a fixed label set — +# anything it returns outside the set is dropped — so it can't invent labels. +# Issues it can't place into any area get `needs-triage` for a human to look at. +# Version (v1/v2) labels are handled separately by issue-version-label.yml. +on: + issues: + types: [opened, reopened] + +permissions: + issues: write + +concurrency: + group: issue-autolabel-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + classify: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + with: + script: | + const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering']; + const PLATFORM = ['windows','macos','linux']; + const SEVERITY = ['crash','data-loss','security']; + const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]); + + const issue = context.payload.issue; + const title = issue.title || ''; + const body = (issue.body || '').slice(0, 4000); + + const system = [ + 'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.', + 'Pick labels ONLY from these fixed sets. Never invent labels.', + 'area (0-2, the affected subsystem):', + ' agent: core agent loop / tool-calling / reasoning', + ' mcp: MCP servers, plugins, codegraph', + ' config: configuration, setup wizard, .toml/.env', + ' updater: auto-update, installer, release packaging', + ' provider: model providers, model selection/switching', + ' desktop: Wails desktop GUI', + ' tui: terminal UI / CLI', + ' skills: skills system', + ' rendering: terminal rendering / flicker / repaint', + 'platform (only if clearly specific to one OS): windows, macos, linux', + 'severity (only if clearly applicable):', + ' crash: app crashes, hangs, or freezes', + ' data-loss: loss of sessions, config, or history', + ' security: credential/secret exposure or a security flaw', + 'Be conservative: omit a label when unsure. The issue may be in Chinese.', + 'Reply with JSON only: {"area":[],"platform":[],"severity":[]}', + ].join('\n'); + + let labels = []; + try { + const res = await fetch('https://api.deepseek.com/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-chat', + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: `Title: ${title}\n\nBody:\n${body}` }, + ], + }), + }); + if (!res.ok) { + core.warning(`DeepSeek API ${res.status}: ${await res.text()}`); + return; + } + const data = await res.json(); + const parsed = JSON.parse(data.choices[0].message.content); + labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])] + .filter(l => ALLOWED.has(l)); + } catch (e) { + core.warning(`Classification failed: ${e.message}`); + return; + } + + if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage'); + + if (labels.length) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: issue.number, + labels, + }); + core.info(`Applied: ${labels.join(', ')}`); + } diff --git a/.github/workflows/issue-version-label.yml b/.github/workflows/issue-version-label.yml new file mode 100644 index 0000000..81c9578 --- /dev/null +++ b/.github/workflows/issue-version-label.yml @@ -0,0 +1,48 @@ +name: Label issue version + +# Issue forms can't let a reporter set a label directly (that needs triage rights), +# so the bug/feature forms carry a "Version line" dropdown and this workflow reads +# the submitted value and applies the matching v1/v2 label on their behalf. +on: + issues: + types: [opened, edited] + +permissions: + issues: write + +concurrency: + group: issue-version-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const body = context.payload.issue.body || ''; + // Pull the "Version line" section out of the rendered issue form. + const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || ''; + const m = section.match(/\bv([12])\b/i); + if (!m) { + core.info('No version line found; nothing to label.'); + return; + } + const choice = 'v' + m[1]; + const other = choice === 'v2' ? 'v1' : 'v2'; + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.issue.number, + labels: [choice], + }); + // Keep v1/v2 mutually exclusive in case an edit flipped the choice. + try { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: context.issue.number, + name: other, + }); + } catch (e) { + core.info(`No ${other} label to remove.`); + } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..f6d03d0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Deploy site + +on: + push: + branches: [main-v2] + paths: ['site/**', '.github/workflows/pages.yml'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: site/package-lock.json + - name: Build + working-directory: site + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm ci + npm run build + - uses: actions/upload-pages-artifact@v5 + with: + path: site/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml new file mode 100644 index 0000000..6f1b429 --- /dev/null +++ b/.github/workflows/pr-auto-label.yml @@ -0,0 +1,28 @@ +name: Auto-label PRs + +# Apply area labels to PRs based on changed paths (see .github/labeler.yml). +# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2) +# labels are handled separately by pr-version-label.yml. +# +# pull_request_target runs in the base repo context so it has the write token +# needed to label PRs from forks; actions/labeler only reads the diff file list +# (it never checks out or runs PR code), so this is safe. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-autolabel-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v6 + with: + sync-labels: false diff --git a/.github/workflows/pr-version-label.yml b/.github/workflows/pr-version-label.yml new file mode 100644 index 0000000..d2d764e --- /dev/null +++ b/.github/workflows/pr-version-label.yml @@ -0,0 +1,49 @@ +name: Label PR version + +# Contributors (especially from forks) can't set labels themselves — that needs +# triage rights. A PR's version line is fully determined by its base branch, so +# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs +# in the base repo's context so it has write access even for fork PRs; it only +# reads trusted metadata (the base ref) and never checks out or runs PR code. +on: + pull_request_target: + types: [opened, reopened, edited] + +permissions: + pull-requests: write + +concurrency: + group: pr-version-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const base = context.payload.pull_request.base.ref; + const label = base === 'v1' ? 'v1' + : base === 'main-v2' ? 'v2' + : null; + if (!label) { + core.info(`Base branch '${base}' isn't a release line; skipping.`); + return; + } + const other = label === 'v2' ? 'v1' : 'v2'; + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.pull_request.number, + labels: [label], + }); + // Drop the other line's label if the base was changed after opening. + try { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: context.payload.pull_request.number, + name: other, + }); + } catch (e) { + core.info(`No ${other} label to remove.`); + } diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000..830b161 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,485 @@ +name: Release desktop + +# Desktop (Wails) release line. Tag namespace `desktop-v` triggers this — +# plain `v` tags are the CLI release (release.yml) and intentionally do NOT +# trigger here (GitHub glob `v*` does not match `desktop-v*`). Bump with: +# git tag desktop-vX.Y.Z && git push origin desktop-vX.Y.Z +# +# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native +# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a +# latest.json manifest is generated, and everything is published to a GitHub +# release and mirrored to R2 (the updater reads R2 first, then the crash worker +# release gateway; stable desktop releases own GitHub's repository-wide "latest"). +# +# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag +# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/ +# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary +# and uploads only to R2's canary/ pointer — no GitHub release, so canary never +# shows on the releases page and never touches latest/. +on: + push: + tags: ["desktop-v*"] + workflow_dispatch: + inputs: + channel: + description: "Release channel" + type: choice + options: [stable, canary] + default: stable + tag: + description: "stable: tag to publish (e.g. desktop-v1.1.0)" + required: false + type: string + base_version: + description: "canary: intended next stable version, e.g. 1.5.0" + required: false + type: string + +permissions: + contents: write # create the release and upload artifacts + +jobs: + cache-guard: + name: cache hit guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Cache hit guard + run: ./scripts/cache-guard.sh + + build: + name: build (${{ matrix.name }}) + needs: cache-guard + permissions: + contents: read # checkout only; the publish job holds contents: write + actions: read # SignPath reads run details + downloads the unsigned artifact + strategy: + fail-fast: false + matrix: + include: + # One universal macOS build (Intel + Apple Silicon) on a single arm64 + # runner — avoids the scarce/slow macos-13 Intel runner. + - { runner: macos-14, platform: darwin/universal, name: darwin-universal } + - { runner: windows-latest, platform: windows/amd64, name: windows-amd64 } + - { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 } + - { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 } + runs-on: ${{ matrix.runner }} + env: + # Windows Authenticode signing engages only when the SignPath token is set, so + # forks / token-less runs still build (unsigned), mirroring the APPLE_* gate. + HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }} + defaults: + run: + shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: actions/setup-node@v6 + with: + node-version: "22" + - uses: pnpm/action-setup@v6 + with: + version: 10 + + # Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh). + # 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+. + - name: Install Linux build deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev + + # Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install + # drops it in ~/go/bin, already on PATH (same place the wails CLI lands below). + - name: Install nfpm + if: runner.os == 'Linux' + run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3 + + # Windows: NSIS provides makensis for `wails build -nsis`. + - name: Install NSIS + if: runner.os == 'Windows' + run: | + choco install nsis -y --no-progress + echo "C:\Program Files (x86)\NSIS" >> "$GITHUB_PATH" + + # macOS: create-dmg packages the .app into a drag-to-Applications .dmg. + - name: Install create-dmg + if: runner.os == 'macOS' + run: brew install create-dmg + + # macOS signing: import the Developer ID cert into a throwaway keychain and + # stage the notarization key. No-ops (and the build ad-hoc signs) when the + # APPLE_* secrets aren't set, so forks still build. + - name: Import Apple signing certificate + if: runner.os == 'macOS' + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + if [ -z "$APPLE_CERT_P12" ]; then + echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)" + exit 0 + fi + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASS="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN" + echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null + # Prepend the signing keychain to the search list so codesign / find-identity see it. + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8" + rm -f "$RUNNER_TEMP/cert.p12" + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + - name: Resolve version + id: ver + run: bash scripts/resolve-desktop-release.sh + env: + EVENT_NAME: ${{ github.event_name }} + IN_CHANNEL: ${{ inputs.channel }} + IN_TAG: ${{ inputs.tag }} + IN_BASE_VERSION: ${{ inputs.base_version }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + + - name: Build and package + env: + # macOS Developer ID + notarization path turns on only when all five + # APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs. + # Harmless on Windows/Linux runners (only the darwin branch reads these). + HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}" + + # Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign: + # the signature rewrites the installer's bytes, so the updater's minisign sig and + # the manifest SHA must be computed over the signed build. SignPath pulls the file + # from the Actions artifact API, so it must be uploaded first. Canary signs with the + # test certificate; stable/rc sign with the Foundation release certificate, but only + # once SIGNPATH_RELEASE_SIGNING_READY is set — until that cert clears CSR-pending, + # stable ships unsigned instead of failing the whole release on an invalid policy. + - name: Upload unsigned installer for SignPath + if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') + id: unsigned-installer + uses: actions/upload-artifact@v7 + with: + name: unsigned-${{ matrix.name }} + path: dist/*installer*.exe + if-no-files-found: error + retention-days: 1 + + - name: Submit installer for Authenticode signing + if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + project-slug: DeepSeek-Reasonix + signing-policy-slug: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }} + artifact-configuration-slug: windows-installer + github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} + github-token: ${{ github.token }} + wait-for-completion: true + # The policy uses an approval process — leave headroom for a human to approve + # the request (the approver gets an email on submit) before the job gives up. + wait-for-completion-timeout-in-seconds: 1800 + output-artifact-directory: signed + + - name: Replace installer with signed build + if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') + run: cp signed/*installer*.exe dist/ + + - name: Sign artifacts (minisign) + working-directory: desktop + env: + MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: go run ./cmd/sign sign ../dist/* + + - uses: actions/upload-artifact@v7 + with: + name: dist-${{ matrix.name }} + path: dist/* + if-no-files-found: error + + publish: + name: publish release + needs: build + runs-on: ubuntu-latest + # Stable publishes go through the `release` environment (esengine must + # approve); canary uses the open `canary` environment so maintainers self-serve. + environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: actions/download-artifact@v8 + with: + path: dist + pattern: dist-* + merge-multiple: true + + - name: Resolve version + id: ver + run: bash scripts/resolve-desktop-release.sh + env: + EVENT_NAME: ${{ github.event_name }} + IN_CHANNEL: ${{ inputs.channel }} + IN_TAG: ${{ inputs.tag }} + IN_BASE_VERSION: ${{ inputs.base_version }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + + # Generate latest.json with GitHub release download URLs; the mirror step + # rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner. + - name: Generate manifest + working-directory: desktop + run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}" + + # Canary never appears on the GitHub releases page; the mirror job picks up + # the signed dist via the canary-dist artifact below. Stable publishes a + # GitHub release as usual. + - name: Publish GitHub release + if: steps.ver.outputs.channel != 'canary' + env: + GH_TOKEN: ${{ github.token }} + run: | + # Keep the repository homepage focused on the installable desktop app; + # the CLI release line is configured not to claim repository-wide latest. + args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes) + if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then + args+=(--prerelease --latest=false) + else + args+=(--latest) + fi + gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}" + + - name: Upload canary dist for mirror + if: steps.ver.outputs.channel == 'canary' + uses: actions/upload-artifact@v7 + with: + name: canary-dist + path: dist/* + if-no-files-found: error + + mirror: + name: mirror to R2 + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # gh release download + compatibility manifest upload + actions: write # dispatch pages.yml to re-bake the site version + # Skip cleanly when R2 isn't configured; GitHub release still works as fallback. + if: ${{ github.repository_owner == 'esengine' }} + env: + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + steps: + - uses: actions/checkout@v7 + + - name: Resolve version + id: ver + run: bash scripts/resolve-desktop-release.sh + env: + EVENT_NAME: ${{ github.event_name }} + IN_CHANNEL: ${{ inputs.channel }} + IN_TAG: ${{ inputs.tag }} + IN_BASE_VERSION: ${{ inputs.base_version }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + + # Canary has no GitHub release — pull the signed dist from the workflow + # artifact. Stable pulls from the published release. + - name: Download canary dist + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary' + uses: actions/download-artifact@v8 + with: + name: canary-dist + path: assets + + - name: Download release assets + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p assets + gh release download "${{ steps.ver.outputs.tag }}" -R "${{ github.repository }}" -D assets + + # Rewrite both url and sig inside latest.json from github.com to the R2 CDN, + # so the updater pulls the manifest AND the heavy artifacts from R2. + - name: Rewrite latest.json URLs to R2 + if: env.HAS_R2 == 'true' + env: + R2_PUBLIC_BASE: https://dl.reasonix.io + TAG: ${{ steps.ver.outputs.tag }} + run: | + f=assets/latest.json + jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" ' + .platforms |= with_entries( + .value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/") + | .value.sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/") + ) + ' "$f" > "$f.new" + mv "$f.new" "$f" + cat "$f" + + - name: Configure AWS CLI for R2 + if: env.HAS_R2 == 'true' + run: | + aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}" + aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}" + aws configure set region auto + + - name: Mirror to R2 + if: env.HAS_R2 == 'true' + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT" + # The pointer a release moves is its channel's alone: canary writes + # canary/, stable writes latest/. A stable prerelease (rc) moves neither. + if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then + aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT" + elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then + echo "prerelease — leaving R2 latest/ untouched" + else + aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT" + fi + + # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot + # protection), so smoke the mirrored objects over the authenticated S3 API + # instead of the public edge. This verifies the mirror landed; the public + # edge itself is not reachable from CI and is covered by end users' traffic. + - name: Smoke desktop release pointers + if: env.HAS_R2 == 'true' + env: + TAG: ${{ steps.ver.outputs.tag }} + VERSION: ${{ steps.ver.outputs.version }} + CHANNEL: ${{ steps.ver.outputs.channel }} + PRERELEASE: ${{ steps.ver.outputs.prerelease }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + f=assets/latest.json + jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" ' + .version == $version and + .download_page == "https://reasonix.io/#start" and + ([.platforms[] | (.url, .sig)] | + all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not))) + ' "$f" >/dev/null + + aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT" + jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null + if [ "$CHANNEL" = "canary" ]; then + pointer="canary" + elif [ "$PRERELEASE" = "true" ]; then + pointer="" + else + pointer="latest" + fi + if [ -n "$pointer" ]; then + aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT" + jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null + fi + + jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do + key="${asset#https://dl.reasonix.io/}" + aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null + done + + # Best-effort probe of the release gateway — the updater's second + # manifest source — over the same public edge and Go client UA end users + # hit. A 403 here is the known Cloudflare bot-protection gap (#6005: + # datacenter/proxy egress gets blocked before the worker runs) and must + # not fail the release until a WAF skip rule for /v1/desktop/releases/* + # lands; it is surfaced as a warning so the run shows whether the edge + # is open. Anything else unexpected (404, 5xx, wrong version) means the + # gateway route or pointer regressed and fails hard. + - name: Probe public release gateway + if: env.HAS_R2 == 'true' + env: + VERSION: ${{ steps.ver.outputs.version }} + CHANNEL: ${{ steps.ver.outputs.channel }} + PRERELEASE: ${{ steps.ver.outputs.prerelease }} + run: | + set -euo pipefail + if [ "$PRERELEASE" = "true" ]; then + echo "prerelease — no pointer moved; skipping gateway probe" + exit 0 + fi + chan="stable" + [ "$CHANNEL" = "canary" ] && chan="canary" + url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json" + # curl already prints 000 for a transport failure; || true keeps -e + # from killing the step so the case below can route it. + code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)" + case "$code" in + 200) + if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then + echo "gateway serves $VERSION on $chan" + else + echo "::error::gateway responded 200 but serves $(jq -r '.version // ""' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer" + exit 1 + fi + ;; + 403) + echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release" + ;; + 000|"") + echo "::warning::gateway unreachable from CI (transport error), not failing the release" + ;; + *) + echo "::error::gateway returned $code for $url — route or pointer regression" + exit 1 + ;; + esac + + - name: Attach desktop manifest to matching CLI release + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + if gh release view "$VERSION" >/dev/null 2>&1; then + gh release upload "$VERSION" assets/latest.json --clobber + else + echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes." + fi + + # Stable release moved R2 latest/ — rebuild the site so its build-time baked + # version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO). + - name: Refresh site to the new version + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run pages.yml --ref main-v2 diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 0000000..7780d2a --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,114 @@ +name: Release npm + +# npm line. Tag namespace `npm-v` triggers this — plain `v` tags +# are the CLI binary + Homebrew release (release.yml) and intentionally do NOT +# trigger here (GitHub glob `npm-v*` does not match `v*`). Splitting npm onto its +# own tag lets a prerelease ship to npm's `next` channel without touching the +# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave +# its rc). Bump with: +# git tag npm-vX.Y.Z # stable -> publishes under `latest` +# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next` +# git push origin +# +# A manual workflow_dispatch can publish either an opt-in canary from the current +# ref or an approved stable/next version when tag creation is restricted. Canary +# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves +# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`, +# any other prerelease is `next`, and a stable version is `latest` (#5822 — the +# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded +# `npm update -g` users). The verify step below asserts the tag actually landed. +on: + push: + tags: ['npm-v*'] + workflow_dispatch: + inputs: + channel: + description: "Publish channel" + required: true + default: canary + type: choice + options: + - canary + - stable + base_version: + description: "Version to publish. Canary appends -canary.; stable publishes exactly this version." + required: true + type: string + +permissions: + contents: read + +jobs: + cache-guard: + name: cache hit guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: ./scripts/cache-guard.sh + + npm: + name: publish npm packages + needs: cache-guard + runs-on: ubuntu-latest + # A tag push (npm-v*) or manual stable dispatch publishes a stable/next + # release and goes through the `release` environment (esengine approves); + # canary dispatches run free. + environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v6 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + # Tag push uses the npm-v* tag. A canary dispatch synthesizes a + # -canary. version; a manual stable dispatch publishes + # the requested semver exactly. build.mjs strips the leading `npm-`/`v`. + - name: Resolve version + id: ver + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + base="${{ inputs.base_version }}"; base="${base#v}" + if [ "${{ inputs.channel }}" = "canary" ]; then + echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + else + echo "arg=v${base}" >> "$GITHUB_OUTPUT" + fi + else + echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + - run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Assert the dist-tag landed where build.mjs aimed it. This is the guard + # that #5822 lacked: `latest` sat on 0.53.2 for months while stables went + # to `next`, and nothing noticed until users got downgraded. The registry + # applies tags asynchronously after publish, hence the poll. + - name: Verify dist-tags + run: | + set -euo pipefail + version="${{ steps.ver.outputs.arg }}" + version="${version#npm-}"; version="${version#v}" + case "$version" in + *-canary.*) tag=canary ;; + *-*) tag=next ;; + *) tag=latest ;; + esac + for attempt in 1 2 3 4 5 6; do + got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)" + if [ "$got" = "$version" ]; then + echo "dist-tag $tag -> $got" + exit 0 + fi + echo "dist-tag $tag -> ${got:-}, want $version (attempt $attempt)" + sleep 10 + done + echo "::error::npm dist-tag '$tag' never landed on $version" + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ae9151a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,173 @@ +name: Release + +# CLI binary line. Tag namespace `v` triggers this — it builds the +# release archives + checksums, publishes the GitHub release, and updates the +# Homebrew cask. npm is a SEPARATE line (release-npm.yml, `npm-v*`) so an npm +# prerelease can ship without forcing the stable cask, and vice versa; desktop is +# `desktop-v*` (release-desktop.yml). A prerelease `v*-rc*` still builds archives +# and a prerelease GitHub release, but goreleaser auto-skips the cask upload +# (brew has no prerelease channel — see .goreleaser.yaml). +on: + push: + tags: ['v*'] + +permissions: + contents: write # create the release and upload archives + +jobs: + cache-guard: + name: cache hit guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: ./scripts/cache-guard.sh + + goreleaser: + name: archives + checksums + homebrew tap + needs: cache-guard + runs-on: ubuntu-latest + # CLI stable release (archives + GitHub release + Homebrew cask). It does not + # claim GitHub's repository-wide Latest badge; that belongs to desktop so the + # repository homepage points users at the installable app. Gated on the + # `release` environment so only esengine can approve it going public. + environment: release + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: goreleaser/goreleaser-action@v7 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Attach desktop manifest compatibility asset + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + case "$TAG" in + *-*) + echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset" + exit 0 + ;; + esac + if [ "$HAS_R2" != "true" ]; then + echo "R2 secrets not configured; skipping desktop manifest compatibility asset" + exit 0 + fi + # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot + # protection), so read the manifest over the authenticated S3 API instead + # of the public edge. + aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}" + aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}" + aws configure set region auto + aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json + jq -e ' + ([.platforms[] | (.url, .sig)] | + all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not))) + ' latest.json >/dev/null + gh release upload "$TAG" latest.json --clobber + + # The compatibility asset exists for pre-v1.16 desktop updaters that + # still poll GitHub's repository-wide latest URL. Desktop releases now + # own that Latest badge, but this check still exercises the public fallback + # path exactly the way those clients fetch it: anonymously, over the public + # edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection + # 403s Actions egress — see the R2 note above), GitHub serves its own + # runners, so this can hard-fail. #5826/#5858 shipped a broken update check + # for weeks precisely because nothing exercised the public path. Retries + # cover the release CDN propagating the freshly uploaded asset. + - name: Smoke public compatibility manifest + env: + TAG: ${{ github.ref_name }} + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + run: | + set -euo pipefail + case "$TAG" in + *-*) + echo "prerelease $TAG — no compatibility asset uploaded; skipping" + exit 0 + ;; + esac + if [ "$HAS_R2" != "true" ]; then + echo "R2 secrets not configured; no compatibility asset uploaded; skipping" + exit 0 + fi + url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json" + for attempt in 1 2 3 4 5 6; do + if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then + jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null + echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))" + exit 0 + fi + echo "attempt $attempt failed; retrying in 10s" + sleep 10 + done + echo "::error::public compatibility manifest unreachable at $url" + exit 1 + + # A stable CLI release must never leave the npm line behind: v1.17.5 + # shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2 + # (#5822) — every `npm update -g` user was silently downgraded to a + # months-old version, and nothing noticed because the npm line + # (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the + # stable npm tag was simply never pushed. release-npm.yml's own verify + # step only guards runs that happen; this guard catches the run that + # DIDN'T. + # + # Two distinct states, two responses (the runs race: both workflows wait + # on the release environment independently, and npm dist-tags propagate + # asynchronously, so "tag pushed but latest not moved yet" is a NORMAL + # mid-release state, not a failure): + # - npm-v tag missing -> hard fail. This is the #5822 gap: + # nobody pushed the npm release at all. + # - tag pushed, latest lagging -> poll briefly, then WARN and pass. + # The npm run may still be awaiting its own environment approval; + # release-npm.yml's verify step owns asserting the dist-tag lands. + - name: Check npm latest dist-tag freshness + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + case "$TAG" in + *-*) + echo "prerelease $TAG — npm latest does not move on prereleases; skipping" + exit 0 + ;; + esac + version="${TAG#v}" + if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then + echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)." + exit 1 + fi + for attempt in 1 2 3 4 5 6; do + got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)" + if [ -n "$got" ]; then + newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)" + if [ "$newest" = "$got" ]; then + echo "npm latest -> $got (>= $version) OK" + exit 0 + fi + fi + echo "npm latest -> ${got:-}, want >= $version (attempt $attempt)" + sleep 10 + done + echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands." + exit 0 diff --git a/.github/workflows/update-acknowledgments.yml b/.github/workflows/update-acknowledgments.yml new file mode 100644 index 0000000..56a870c --- /dev/null +++ b/.github/workflows/update-acknowledgments.yml @@ -0,0 +1,37 @@ +name: Update acknowledgments + +on: + workflow_dispatch: + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Update README contributor tables + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/update-acknowledgments.mjs + - name: Open pull request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: chore/update-acknowledgments + delete-branch: true + title: 'Update acknowledgments contributors / 更新致谢贡献者' + commit-message: 'docs: update acknowledgments contributors' + body: | + Refreshes the README acknowledgments tables from the GitHub contributors API. + + - Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1` + - Anonymous contributors are shown without email addresses. + labels: documentation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbfdf4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Build artifacts +/bin/ +/dist/ +/stage/ +/npm/.stage/ +/reasonix +/reasonix.exe + +# Website (Astro) build output +/site/dist/ +/site/.astro/ +/site/_preview.png + +# Go test/build scratch +*.test +*.out +*.coverprofile +coverage.out +cpu.prof +mem.prof + +# Wails desktop artifacts +/desktop/build/* +!/desktop/build/appicon.png +!/desktop/build/appicon.svg +/desktop/desktop +/desktop/frontend/dist/* +!/desktop/frontend/dist/.gitkeep +/desktop/frontend/sourcemaps/ +/desktop/frontend/wailsjs/ +/desktop/frontend/package.json.md5 + +# Environment / local config (reasonix.example.toml is the shared template) +.env +.env.local +*.local +/reasonix.toml +/desktop/.env +/desktop/reasonix.toml + +# Node / frontend dependencies and caches +node_modules/ +.pnpm-store/ +.npm/ + +# Project-local Reasonix state; keep the checked-in review command. +/.reasonix/* +!/.reasonix/commands/ +!/.reasonix/commands/review.md + +# CodeGraph per-project index (rebuilt locally; never committed) +.codegraph/ + +# Editor / OS +.DS_Store +Thumbs.db +.idea/ +.vscode/ +/site/public/av/ + +benchmarks/context-maintenance-e2e/run/ + +# Local scratch +temp/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..fcfdb9a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,48 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + settings: + errcheck: + check-type-assertions: false + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - (*net/http.Response).Body.Close + - golang.org/x/term.Restore + staticcheck: + checks: + - all + - -ST1005 # error strings ending with punctuation — too strict for initial adoption + - -ST1018 # Unicode format characters — intentional in TUI code + - -QF1001 # De Morgan's law — readability preference + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + # Setup/teardown in tests routinely ignores cleanup errors. + - path: _test\.go + linters: + - errcheck + # The pinned golangci-lint binary's staticcheck reports SA5011 false + # positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it + # doesn't treat t.Fatal as terminating); the same code is clean under a + # locally-built golangci-lint. Scope the suppression to tests so SA5011 + # still guards production code. + - path: _test\.go + linters: + - staticcheck + text: SA5011 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9e0fae1 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,62 @@ +version: 2 +project_name: reasonix + +before: + hooks: + - go mod download + +builds: + - id: reasonix + main: ./cmd/reasonix + binary: reasonix + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Tag }} + goos: [darwin, linux, windows] + goarch: [amd64, arm64] + +archives: + - id: reasonix + ids: [reasonix] + name_template: "reasonix-{{ .Os }}-{{ .Arch }}" + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: SHA256SUMS + algorithm: sha256 + +homebrew_casks: + - name: reasonix + ids: [reasonix] + # Prereleases (v*-rc*) must not update the stable tap; brew has no separate + # prerelease channel, so a pushed rc cask becomes the default `brew install`. + # npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no + # longer drags brew along — a stable `v*` cask can ship while npm stays on rc. + skip_upload: "auto" + repository: + owner: esengine + name: homebrew-reasonix + branch: main + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + homepage: "https://github.com/esengine/DeepSeek-Reasonix" + description: "Cache-first DeepSeek coding agent for the terminal." + commit_author: + name: reasonix + email: reasonix@deepseek.com + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"] + end + +release: + prerelease: auto + make_latest: false + name_template: "Reasonix CLI {{ .Tag }}" diff --git a/.reasonix/commands/review.md b/.reasonix/commands/review.md new file mode 100644 index 0000000..1de8a82 --- /dev/null +++ b/.reasonix/commands/review.md @@ -0,0 +1,5 @@ +--- +description: Review a file for bugs +argument-hint: [path] +--- +Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS. diff --git a/.signpath/artifact-configurations/windows-installer.xml b/.signpath/artifact-configurations/windows-installer.xml new file mode 100644 index 0000000..8dc41ff --- /dev/null +++ b/.signpath/artifact-configurations/windows-installer.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c7076cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy +`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) +branch. + +## Unreleased + +### Changed + +- Agent runtime defaults now leave both executor and dedicated planner tool-call + rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now + come from the user/global config only; project `reasonix.toml` does not + override them. + +## [1.0.0] — 2026-06-03 + +First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x` +TypeScript line; a new codebase that becomes the default (`main-v2`). + +### Highlights + +- **Go kernel**: a single static binary (CGO-free), cross-compiled for + darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps + the native binary), Homebrew (`esengine/reasonix` tap), and release archives; + no Node runtime needed to run it. +- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/ + ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the + DeepSeek prefix-cache–oriented design. +- **Subagents**: `task` plus explore/research/review/security_review skill agents. +- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks + (`internal/hook`), symlink-aware and slash-integrated. +- **MCP client**: connect external servers over stdio / Streamable HTTP; reads + `[[plugins]]` and a Claude-Code `.mcp.json`. +- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph + (`codegraph_*` tools) replaces embedding semantic search — no embedding service + or API cost. Fetched into a local cache on first use (or `reasonix codegraph + install`) and indexed in the background, so installs and startup stay fast. +- **Plan mode** with evidence-backed step sign-off (`complete_step`). +- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable + prefix. +- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails). + +### Fixed + +- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files + can now be read, edited, and grepped correctly. The v2 rewrite had dropped + v1's encoding detection; files in CJK Windows charsets were silently misread + or rejected as binary. The read/edit/write round-trip now preserves the + original file encoding. (#2637) + +### Notes + +- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at + `1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md). +- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows + support for the fetched runtime is unverified — install `codegraph` on PATH if + the auto-fetch doesn't resolve there. + +[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4c0cf82 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,190 @@ +# Contributing to Reasonix + +Thank you for your interest in contributing to Reasonix! This guide covers +everything you need to get started. + +## Prerequisites + +- **Go 1.25+** — the project targets the latest stable Go release +- **Git** — for version control +- **Node.js** (optional) — only if you work on the desktop app (`desktop/`) + +## Getting started + +```bash +git clone https://github.com/esengine/DeepSeek-Reasonix.git +cd DeepSeek-Reasonix +go build ./cmd/reasonix # builds the CLI binary +go test ./... # runs the full test suite +``` + +## Project structure + +| Directory | Purpose | +|-----------|---------| +| `cmd/reasonix` | CLI entry point | +| `internal/agent` | Agent loop, session, coordinator | +| `internal/cli` | TUI, subcommands, setup wizard | +| `internal/control` | Transport-agnostic controller | +| `internal/config` | TOML configuration loading | +| `internal/tool/builtin` | Built-in tools (bash, read_file, …) | +| `internal/provider` | Model-backend abstraction | +| `internal/provider/openai` | OpenAI-compatible provider | +| `internal/plugin` | MCP client (stdio + HTTP) | +| `internal/event` | Typed event stream | +| `internal/hook` | Shell hooks (PreToolUse, …) | +| `internal/memory` | REASONIX.md hierarchy + auto-memory | +| `internal/skill` | Skill discovery from Markdown | +| `internal/sandbox` | OS-level sandboxing | +| `internal/serve` | HTTP/SSE server frontend | +| `internal/checkpoint` | Snapshot-based rewind | +| `desktop/` | Wails-based desktop app (separate Go module) | +| `docs/` | Engineering spec, migration guide | + +### Dependency direction + +``` +cli → {agent, plugin, config} → {tool, provider} +``` + +Built-in subpackages import their parent to self-register via `init()`. +Parents never import children. + +## Development workflow + +### Building + +```bash +make build # go build ./... +make test # go test ./... +make vet # go vet ./... +make fmt # gofmt -w . +make hooks # install git hooks (pre-push: go vet) +make cross # cross-compile for all 6 targets +``` + +### Isolated development environment + +A source-built binary shares no on-disk state with a stable release when launched +with `REASONIX_HOME` set. This gives each build its own self-contained directory +tree — config, credentials, sessions, cache, skills, commands, hooks, and +desktop tab state — so the two builds never interfere: + +**CLI** + +```bash +REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix +# or after building: +# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix +``` + +**Desktop** + +```bash +cd desktop && wails build +REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop +``` + +On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in +Command Prompt; the binary extension is `.exe`. + +The directory is empty on first launch; the app behaves exactly like a fresh +install. Every subsequent write — config saves, credential storage, session +logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention +directory scanning, and all other fallback paths are skipped so no production +data leaks in or out. + +### Cache-first review gate + +Reasonix treats high prompt-cache hit rate as product behavior. Changes that +touch provider-visible system prompt construction, memory prefix, output styles, +skill index behavior, default tool surfaces, tool schemas, provider request +serialization, compaction, or MCP/tool registration need explicit cache review. + +For these changes: + +- Keep system prompt changes low-frequency and require explicit review. +- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high` + plus the reason. +- Fill the PR body `Cache-guard:` line with the focused guard test/command added + or run, or explain why an existing guard covers the change. +- Fill `System-prompt-review:` when system prompt, memory prefix, output style, + or skill index behavior changes. +- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh` + remains the broader release-level cache-hit check. + +CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn +is called out before review. + +### Running tests + +```bash +go test ./... # all tests +go test ./internal/agent/ -v # verbose, one package +go test ./internal/tool/builtin/ -run TestGrep # one test +``` + +### Code style + +- `gofmt` is enforced by CI — format before committing +- Follow existing patterns: wrap errors with `fmt.Errorf("...: %w", err)` +- Library code never calls `os.Exit` or prints to stdout/stderr +- Only `cli/` and `main/` decide exit codes and user-facing messages +- Exported identifiers must have doc comments + +### Commit messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(glob): add ** recursive pattern support +fix: replace silent error discards with structured logging +test(event): add comprehensive unit tests for event package +docs: add CONTRIBUTING.md +ci: add golangci-lint and govulncheck +``` + +## Adding a new built-in tool + +1. Create `internal/tool/builtin/mytool.go` +2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()` +3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }` +4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go` +5. The tool is automatically available — `main` blank-imports `builtin` + +## Adding a new model provider + +(For MCP tool servers see `internal/plugin` instead — that's a different layer.) + +1. Create `internal/provider/myprovider/` +2. Implement `provider.Provider`: `Name()`, `Stream()` +3. Register via `func init() { provider.Register("mykind", New) }` +4. The provider is available from config with `kind = "mykind"` + +## Adding i18n strings + +1. Add the field to `internal/i18n/i18n.go` (`Messages` struct) +2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go` +3. The `TestCatalogsComplete` test will fail if you miss a locale + +## Submitting changes + +1. Fork the repository +2. Create a feature branch from `main-v2` +3. Make your changes with tests +4. Ensure `go test ./...` passes +5. Ensure `gofmt -l .` shows no changes +6. Submit a pull request to `main-v2` + +## Reporting issues + +Open an issue on GitHub with: +- Steps to reproduce +- Expected vs actual behavior +- Go version and OS +- Relevant logs or error messages + +## License + +By contributing, you agree that your contributions will be licensed under the +same license as the project. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc45a28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reasonix Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..81ac501 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) +GOEXE := $(shell go env GOEXE) + +.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean + +build: + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example$(GOEXE) ./cmd/reasonix-plugin-example + +vet: + go vet ./... + +fmt: + gofmt -w . + +test: + go test ./... + +desktop-test: + cd desktop && go test . + +desktop-test-short: + cd desktop && go test -short . + +desktop-test-times: + cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py + +hooks: + @git config core.hooksPath .githooks + @echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)" + +cross: + @mkdir -p dist + @for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \ + os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \ + echo "build $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \ + done + +clean: + rm -rf bin dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..e36bffc --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +

+ Reasonix +

+ +

+ English +  ·  + 简体中文 +  ·  + Guide +  ·  + Spec +  ·  + Website +  ·  + Discord +

+ +> [!IMPORTANT] +> **Reasonix 1.0 is a ground-up rewrite in Go** — this branch (`main-v2`) is the new default and where development happens now. +> The earlier `0.x` TypeScript releases are **legacy**, living on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) branch (maintenance only). +> See the **[migration guide](./docs/MIGRATING.md)**. `npm i -g reasonix` stays the install command — `1.0.0`+ delivers the Go binary, `0.x` is the legacy TS build. + +

+ npm version + CI + license + downloads + GitHub stars + AtomGit stars + contributors + Discussions + Discord +

+ +
+ +

A DeepSeek-native AI coding agent for your terminal.

+

A config- and plugin-driven harness — a single static Go binary, tuned around DeepSeek's prefix cache so token costs stay low across long sessions.

+ +
+ +> [!IMPORTANT] +> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **** + +
+ +## Features + +- **Config-driven.** Providers, the agent, enabled tools, and plugins are all + declared in `reasonix.toml`. No hardcoded models. +- **Multi-model & composable.** DeepSeek ships as a preset; any + OpenAI-compatible endpoint is a config entry, not new code. Optionally run + two models together (executor + planner) in separate, cache-stable sessions. +- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC + (MCP-compatible). Built-in tools self-register at compile time. +- **Cache-aware context maintenance.** Startup injects a small stable environment + summary, stale tool output is snipped/pruned before summary compaction, and the + built-in tool schema contract is documented for regression review. +- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile + to six targets with one command. The only dependency is a TOML parser. + +## Install + +```sh +npm i -g reasonix # any OS; pulls the prebuilt native binary +brew install esengine/reasonix/reasonix # macOS +``` + +Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on +every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases). + +### Code signing + +Windows builds are code-signed with a free certificate provided by the +[SignPath Foundation](https://signpath.org/), with signing through +[SignPath.io](https://signpath.io/). + +### Build from source + +```sh +make build # -> bin/reasonix(.exe) +make cross # -> dist/ (darwin|linux|windows × amd64|arm64) +``` + +## Quick start + +```sh +reasonix setup # config wizard → ./reasonix.toml +export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env +reasonix # then run /init to generate AGENTS.md (project memory) +reasonix run "implement the TODOs in main.go" +reasonix run --model deepseek-pro "add unit tests for this function" +echo "explain this code" | reasonix run +``` + +## Configuration + +A minimal `reasonix.toml` — one provider and a default model — is enough to start: + +```toml +default_model = "deepseek-flash" + +[[providers]] +name = "deepseek-flash" +kind = "openai" +base_url = "https://api.deepseek.com" +model = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" +``` + +Resolution order is **flag > `./reasonix.toml` > the user config file > +built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at +`~/.reasonix/config.toml` on macOS/Linux and +`%AppData%\reasonix\config.toml` on Windows. See +**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the +full `config.toml` / `.env` structure. Provider entries name secrets with +`api_key_env`; the secret values themselves live in Reasonix's global +`/.env`, shared by CLI and desktop. Project `.env` files are not +provider-key runtime fallbacks, but still feed workspace-scoped, non-provider +`${VAR}` expansion for MCP/plugin settings without importing Reasonix control +variables. Permissions, the sandbox, plugins (MCP), slash +commands, `@` references, and two-model setup are all in the +**[Guide](./docs/GUIDE.md)**. + +## Documentation + +- **[Guide](./docs/GUIDE.md)** — configuration, permissions & sandbox, plugins + (MCP), slash commands, `@` references, two-model collaboration. +- **[Subagent profiles](./docs/SUBAGENT_PROFILES.md)** — create, share, preview, + run, edit, and safely delete isolated agent profiles from desktop or CLI. +- **[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md)** — + `reasonix doctor capabilities`, desktop Settings → Diagnostics, and the + `/reasonix-guide` skill for skills/hooks/MCP/plugin troubleshooting. +- **[Bot guide](./docs/BOT_GUIDE.md)** — connect Feishu, Lark, and WeChat bots + from the desktop app, then use approvals, YOLO, and commands from IM. +- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries, + data types, and roadmap. +- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure + complex requests with context, output boundaries, constraints, and when to ask. +- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool + names, read-only flags, and schema snapshot guard. +- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy + TypeScript releases to the 1.0 Go rewrite. +- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit + safety net (Esc-Esc / `/rewind`). + +
+ +## Star History + + + + + + Star History Chart + + + +
+ +## Support + +If Reasonix has been useful and you'd like to say thanks, you can. It stays a coffee, not a contract — donations don't buy feature priority or change how issues get triaged. + +- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui) +- **国内** — 微信支付(扫码) + +

+ WeChat Pay QR code +

+ +
+ +## Acknowledgments + +A small list of folks whose work has shaped Reasonix the most — the current top +20 contributors by commit count. The full contributor graph is on +[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1). + + +| Contributor | Contributor | Contributor | Contributor | +| --- | --- | --- | --- | +| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) | +| **reasonix** (anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) | +| **merge-order-check** (anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** (anonymous) | +| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) | +| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) | + + +Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123) +for designing the project logo, and to +[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu. + +

+ + Contributors to esengine/DeepSeek-Reasonix + +

+ +
+ +--- + +

+ MIT — see LICENSE +
+ Built by the community at esengine/DeepSeek-Reasonix +

diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..9a9d8bd --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`esengine/DeepSeek-Reasonix` +- 原始仓库:https://github.com/esengine/DeepSeek-Reasonix +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..585a648 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,195 @@ +

+ Reasonix +

+ +

+ English +  ·  + 简体中文 +  ·  + 指南 +  ·  + 规格 +  ·  + 官方网站 +  ·  + Discord +

+ +> [!IMPORTANT] +> **Reasonix 1.0 是用 Go 从零重写的版本** —— 本分支(`main-v2`)已是新的默认分支,后续开发都在这里。 +> 早期的 `0.x` TypeScript 版本转为 **legacy**,保留在 [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) 分支(仅维护)。 +> 详见**[迁移指南](./docs/MIGRATING.md)**。`npm i -g reasonix` 仍是安装命令——`1.0.0`+ 装的是 Go 二进制,`0.x` 是 legacy TS 版。 + +

+ npm version + CI + license + downloads + GitHub stars + AtomGit stars + contributors + Discussions + Discord +

+ +
+ +

面向终端的 DeepSeek 原生 AI coding agent。

+

由配置与插件驱动的极薄 harness——单一静态 Go 二进制,围绕 DeepSeek 的前缀缓存调优,长会话也能把 token 成本压低。

+ +
+ +> [!IMPORTANT] +> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **** + +## 特性 + +- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明, + 内核无硬编码模型。 +- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容 + 端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。 +- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容); + 内置工具在编译期自注册。 +- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune, + 再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。 +- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。 + 唯一依赖是一个 TOML 解析库。 + +## 安装 + +```sh +npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制 +brew install esengine/reasonix/reasonix # macOS +``` + +预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个 +[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。 + +### 代码签名 + +Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书, +通过 [SignPath.io](https://signpath.io/) 完成签名。 + +### 从源码构建 + +```sh +make build # -> bin/reasonix(.exe) +make cross # -> dist/(darwin|linux|windows × amd64|arm64) +``` + +## 快速开始 + +```sh +reasonix setup # 配置向导 → ./reasonix.toml +export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env +reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆) +reasonix run "把 main.go 里的 TODO 实现掉" +reasonix run --model deepseek-pro "给这个函数补单元测试" +echo "解释这段代码" | reasonix run +``` + +## 配置 + +一个最小的 `reasonix.toml`——一个 provider 加一个默认模型——就够跑起来: + +```toml +default_model = "deepseek-flash" + +[[providers]] +name = "deepseek-flash" +kind = "openai" +base_url = "https://api.deepseek.com" +model = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" +``` + +优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从 +**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`, +Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见 +**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml` +和 `.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在 +CLI 与桌面端共用的 Reasonix 全局 `/.env`;项目 `.env` 不再作为 +provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、 +斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。 + +## 文档 + +- **[指南](./docs/GUIDE.zh-CN.md)** —— 配置、权限与沙盒、插件(MCP)、斜杠命令、 + `@` 引用、双模型协同。 +- **[子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md)** —— 在桌面端或 CLI + 创建、共享、预览、运行、编辑和安全删除隔离智能体 Profile。 +- **[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md)** —— + `reasonix doctor capabilities`、桌面端 **设置 → 诊断**,以及内置 Skill + `/reasonix-guide`,用于 skills / hooks / MCP / 插件排障。 +- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信 + Bot,以及 IM 里的审批、YOLO 和命令交互。 +- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。 +- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。 +- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、 + read-only 标记和 schema 快照保护。 +- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。 +- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网 + (Esc-Esc / `/rewind`)。 + +
+ +## Star 趋势 + + + + + + Star History Chart + + + +
+ +## 支持本项目 + +如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature 优先级,也不会影响 issue 的处理顺序——就是「谢谢」。 + +- **国内** — 微信支付(扫下方二维码) +- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui) + +

+ 微信支付收款码 +

+ +
+ +## 致谢 + +下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。 +完整贡献者列表在 +[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1)。 + + +| Contributor | Contributor | Contributor | Contributor | +| --- | --- | --- | --- | +| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) | +| **reasonix**(anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) | +| **merge-order-check**(anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan**(anonymous) | +| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) | +| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) | + + +另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo, +以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。 + +

+ + esengine/DeepSeek-Reasonix 贡献者 + +

+ +
+ +--- + +

+ MIT —— 见 LICENSE +
+ esengine/DeepSeek-Reasonix 社区共建 +

diff --git a/REASONIX.md b/REASONIX.md new file mode 100644 index 0000000..abb7797 --- /dev/null +++ b/REASONIX.md @@ -0,0 +1,73 @@ +# Reasonix project memory + +This file is loaded into every session's system prompt (the cache-stable prefix), +so keep it concise and durable — it is the project's standing instructions to the +agent. It is the Reasonix analog of Claude Code's CLAUDE.md. + +## Conventions + +- Go kernel under `internal/`; each package owns one concern and documents it in a + package comment. Match the surrounding comment density and idiom when editing. +- One transport-agnostic `control.Controller` sits behind every frontend (chat + TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a + frontend, so all three inherit it. +- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay + byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never + mutate it mid-session — ride the turn tail instead (see `control.Compose`). + +## Memory + +- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md` + (personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any + `REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name. +- `@path` on its own line imports another file's contents. +- `#` in chat quick-adds a line here. The `remember` tool saves durable + facts to the per-project auto-memory store (frontmatter files + `MEMORY.md` + index), which loads into the prefix on the next session. + +## Notes + +## Pre-push CI simulation + +Run these **before every commit** to catch the fastest CI failures locally: + +```bash +gofmt -w . # catches gofmt (saves ~13s CI) +go vet ./... # catches vet warnings (saves ~52s CI/lint) +go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks +``` + +CI runs `golangci-lint` (not locally available), but gofmt + vet already block ~80% of fast-fail scenarios. + +## Import cycle rule + +Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you: + +``` +# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed +``` + +Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists. + +## PR hygiene + +- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers. +- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches. +- **Amend, don't add commits, for review feedback** — keeps the commit history clean. + +## Cache-impact PR metadata + +When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end: + +``` +Cache-impact: +Cache-guard: +``` + +If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add: + +``` +System-prompt-review: +``` + +Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4b9ecac --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,137 @@ +# Security Policy + +## Supported Versions + +Reasonix security fixes are prioritized for the currently developed Go rewrite +and the current 1.x release line. + +| Version or branch | Security support | +| --- | --- | +| `main-v2` / 1.x releases | Supported | +| `v1` / 0.x legacy branch | Critical fixes only, where practical | +| Older releases, forks, or modified builds | Not covered unless the issue is reproducible upstream | + +If you are unsure whether a version is affected, report against the newest +released 1.x version and include the exact version or commit you tested. + +## Reporting a Vulnerability + +Please report security issues privately. Do not open a public issue with exploit +details, secrets, crash dumps, or proof-of-concept payloads. + +Preferred reporting path: + +1. Use GitHub private vulnerability reporting for this repository, if available. +2. If private reporting is not available to you, open a minimal public issue + asking for a private maintainer contact path. Do not include exploit details + in that issue. + +Please include: + +- Affected Reasonix version, commit, operating system, and installation method. +- The feature or surface involved, such as CLI, desktop app, HTTP `serve`, bot + gateway, MCP plugin, built-in tool, updater, or configuration loading. +- Clear reproduction steps using dummy credentials and non-sensitive files. +- The expected impact, such as secret disclosure, arbitrary file access, + command execution, sandbox escape, authentication bypass, or supply-chain risk. +- Any relevant logs with API keys, tokens, local paths, and personal data + redacted. + +Do not send real provider API keys, bot credentials, OAuth tokens, private +workspace files, or third-party user data. + +## Security Boundaries + +Reasonix is a local coding agent. Many features intentionally operate on the +user's local machine and workspace, including file reads, file writes, shell +commands, MCP plugins, language servers, bot sessions, and model-provider +requests. A finding is security-relevant when it crosses a supported boundary or +bypasses an explicit guard. + +Supported boundaries include: + +- Workspace confinement for file operations that are documented or implemented + as workspace-scoped. +- Permission checks for tool calls, shell commands, file writes, and approvals. +- Sandbox behavior for built-in shell execution where the platform supports it. +- Secret handling for provider keys, bot credentials, OAuth tokens, plugin + headers, and credential-store fallback files. +- HTTP `serve` protections for the unauthenticated local server, including + localhost binding assumptions, JSON-only state-changing requests, and CORS + restrictions. +- Desktop and bot session isolation, including per-workspace session metadata + and configured bot allowlists. +- Updater, install, and release verification paths. + +The following are normally treated as trusted local/operator-controlled inputs +unless another bug lets an untrusted actor supply them: + +- CLI arguments and text typed directly by the local user. +- Project configuration files intentionally loaded from the current workspace. +- Explicit `@path` references supplied by the local user to attach local files. +- MCP servers, language servers, hooks, and slash commands installed or enabled + by the local user. +- Provider base URLs and model names configured by the local user. + +The following can be security issues when reachable by an untrusted actor or +when they bypass the intended boundary: + +- Reading or writing files outside the configured workspace without explicit + local-user intent. +- Following symlinks or path traversal to escape workspace confinement. +- Running shell commands or external tools without the required permission gate. +- Leaking credentials, environment variables, prompt history, local files, or bot + messages to logs, model providers, MCP servers, crash reports, or telemetry. +- Allowing a website to drive the local HTTP server through CSRF, CORS, or + content-type bypasses. +- Letting a bot user outside the configured allowlist submit prompts, approve + tools, or access a project workspace. +- Trusting unverified update artifacts, plugin definitions, or downloaded + binaries. + +## `@` File References + +Reasonix supports `@path` references so users can include local files and images +in a prompt. This is intentional local functionality, but implementations must +preserve these invariants: + +- In workspace-scoped sessions, relative and absolute paths must resolve under + the active workspace root before file content is read or attached. +- Path traversal such as `..` must not escape the workspace root. +- Symlinks must not be usable to bypass the intended workspace boundary. +- Unscoped local CLI compatibility must not be exposed to remote, bot, or + browser-controlled inputs unless an equivalent workspace boundary is applied. +- File content should be size-limited and binary content should not be dumped as + prompt text. + +Static analysis alerts about path expressions should be triaged against these +rules: user-controlled path data is expected, but the access must either stay +inside the configured workspace or be clearly limited to trusted local CLI use. + +## Out of Scope + +The following reports are usually out of scope unless they demonstrate a bypass +of one of the boundaries above: + +- A local user intentionally asks Reasonix to read, edit, or send their own + files to a configured model provider. +- A local user installs or enables a malicious MCP server, hook, slash command, + language server, or shell command and then grants it permission. +- A configured model provider, proxy, or MCP server receives data the user + intentionally sent to it. +- Denial-of-service issues that only affect the local user's own session and do + not corrupt files, leak secrets, or bypass permissions. +- Issues requiring administrator/root access on the user's machine before + interacting with Reasonix. +- Vulnerabilities in third-party services, models, proxies, or plugins that are + not caused by Reasonix behavior. + +## Coordinated Disclosure + +This is a community-maintained project. Maintainers will make a best-effort +assessment, ask follow-up questions when needed, and coordinate fixes before +public disclosure for confirmed vulnerabilities. + +Please give maintainers reasonable time to investigate and release a fix before +publishing exploit details. If you plan to disclose on a timeline, include that +timeline in your initial report. diff --git a/benchmarks/context-maintenance-e2e/main.go b/benchmarks/context-maintenance-e2e/main.go new file mode 100644 index 0000000..fda902a --- /dev/null +++ b/benchmarks/context-maintenance-e2e/main.go @@ -0,0 +1,255 @@ +// Drives the context-maintenance E2E scenarios against the real DeepSeek API: +// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "reasonix/internal/agent" + "reasonix/internal/event" + "reasonix/internal/provider" + _ "reasonix/internal/provider/openai" + "reasonix/internal/tool" + "reasonix/internal/tool/builtin" +) + +const ( + model = "deepseek-v4-flash" + baseURL = "https://api.deepseek.com" + fatResults = 20 + fatBytes = 12_000 +) + +func prov() provider.Provider { + key := os.Getenv("DEEPSEEK_API_KEY") + if key == "" { + fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set") + os.Exit(1) + } + p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return p +} + +func fakeGoFile(nonce string, i, size int) string { + var b strings.Builder + fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i) + line := 0 + for b.Len() < size { + fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7) + line++ + } + return b.String() +} + +func seedSession(nonce string) *agent.Session { + s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.") + s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."}) + for i := 0; i < fatResults; i++ { + id := fmt.Sprintf("c%02d", i) + name := fmt.Sprintf("src/file%02d.go", i) + s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}}) + s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)}) + s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)}) + } + return s +} + +// oneShot appends a user message and runs a single completion, returning usage. +func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) { + req := provider.Request{ + Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}), + MaxTokens: 32, + } + ch, err := p.Stream(context.Background(), req) + if err != nil { + return provider.Usage{}, err + } + var u provider.Usage + for c := range ch { + switch c.Type { + case provider.ChunkUsage: + u = *c.Usage + case provider.ChunkError: + return u, c.Err + } + } + return u, nil +} + +type meta struct { + SeededAt time.Time `json:"seeded_at"` + Nonces map[string]string `json:"nonces"` + SeedUse map[string]provider.Usage `json:"seed_usage"` +} + +func seed(dir string) { + p := prov() + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}} + for _, arm := range []string{"pruned", "control"} { + nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000) + s := seedSession(nonce) + u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.") + if err != nil { + fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err) + os.Exit(1) + } + if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m.Nonces[arm] = nonce + m.SeedUse[arm] = u + fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens) + } + b, _ := json.MarshalIndent(m, "", " ") + if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func resume(dir string) { + p := prov() + raw, err := os.ReadFile(filepath.Join(dir, "meta.json")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + var m meta + if err := json.Unmarshal(raw, &m); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + idle := time.Since(m.SeededAt).Round(time.Minute) + + out := map[string]provider.Usage{} + for _, arm := range []string{"pruned", "control"} { + s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + prunes := 0 + if arm == "pruned" { + a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard) + st, err := a.PruneStaleToolResults() + if err != nil { + fmt.Fprintln(os.Stderr, "prune:", err) + os.Exit(1) + } + prunes = st.Results + } + u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.") + if err != nil { + fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err) + os.Exit(1) + } + out[arm] = u + fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens) + } + b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ") + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + c, pr := out["control"], out["pruned"] + if c.CacheMissTokens > 0 { + fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n", + c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens))) + } +} + +// comprehension checks that the agent re-reads a file behind a prune placeholder +// instead of hallucinating: the answer is a number that exists only in the file. +func comprehension(trials int) { + p := prov() + pass := 0 + for t := 0; t < trials; t++ { + dir, err := os.MkdirTemp("", "cm-e2e-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000) + content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400) + if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.") + s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."}) + s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}}) + s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content}) + s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."}) + for i := 0; i < 4; i++ { + s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)}) + s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) + } + + reg := tool.NewRegistry() + for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") { + reg.Add(tl) + } + a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard) + st, err := a.PruneStaleToolResults() + if err != nil || st.Results == 0 { + fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err) + os.Exit(1) + } + + err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.") + reRead, answered := false, false + for _, msg := range s.Snapshot() { + if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) { + reRead = true + } + if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) { + answered = true + } + } + ok := err == nil && reRead && answered + if ok { + pass++ + } + fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err) + os.RemoveAll(dir) + } + fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials) + if pass < trials { + os.Exit(1) + } +} + +func main() { + dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume") + trials := flag.Int("trials", 5, "comprehension trials") + flag.Parse() + switch flag.Arg(0) { + case "seed": + seed(*dir) + case "resume": + resume(*dir) + case "comprehension": + comprehension(*trials) + default: + fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]") + os.Exit(1) + } +} diff --git a/benchmarks/e2e/tasks/compaction/task.toml b/benchmarks/e2e/tasks/compaction/task.toml new file mode 100644 index 0000000..ff6b91c --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/task.toml @@ -0,0 +1,3 @@ +prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else." +max_steps = 40 +timeout_sec = 900 diff --git a/benchmarks/e2e/tasks/compaction/verify.sh b/benchmarks/e2e/tasks/compaction/verify.sh new file mode 100644 index 0000000..c9ec1b6 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/verify.sh @@ -0,0 +1,4 @@ +set -e +got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]') +want="aldermoor-verrin" +[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; } diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md new file mode 100644 index 0000000..4070cdd --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md @@ -0,0 +1,220 @@ +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +To pick up the trail again, the next page you must read is chapter-4.md. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md new file mode 100644 index 0000000..0cfbb43 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md @@ -0,0 +1,220 @@ +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +To pick up the trail again, the next page you must read is chapter-5.md. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md new file mode 100644 index 0000000..46d895b --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md @@ -0,0 +1,220 @@ +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +To pick up the trail again, the next page you must read is chapter-6.md. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md new file mode 100644 index 0000000..61f84af --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md @@ -0,0 +1,220 @@ +Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +To pick up the trail again, the next page you must read is chapter-2.md. +Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md new file mode 100644 index 0000000..24f6210 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md @@ -0,0 +1,220 @@ +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +To pick up the trail again, the next page you must read is chapter-3.md. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md new file mode 100644 index 0000000..828e100 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md @@ -0,0 +1,220 @@ +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. diff --git a/benchmarks/e2e/tasks/fix-add-bug/task.toml b/benchmarks/e2e/tasks/fix-add-bug/task.toml new file mode 100644 index 0000000..fe3456b --- /dev/null +++ b/benchmarks/e2e/tasks/fix-add-bug/task.toml @@ -0,0 +1,3 @@ +prompt = "The file calc.py has a bug: add(a, b) returns the wrong result. Fix add so it returns the sum of a and b. Keep the other functions unchanged." +max_steps = 12 +timeout_sec = 180 diff --git a/benchmarks/e2e/tasks/fix-add-bug/verify.sh b/benchmarks/e2e/tasks/fix-add-bug/verify.sh new file mode 100644 index 0000000..4bb24c0 --- /dev/null +++ b/benchmarks/e2e/tasks/fix-add-bug/verify.sh @@ -0,0 +1,7 @@ +set -e +python3 - <<'PY' +import calc +assert calc.add(2, 3) == 5, calc.add(2, 3) +assert calc.add(10, 5) == 15, calc.add(10, 5) +assert calc.mul(2, 3) == 6, calc.mul(2, 3) +PY diff --git a/benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py b/benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py new file mode 100644 index 0000000..f905fb9 --- /dev/null +++ b/benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py @@ -0,0 +1,6 @@ +def add(a, b): + return a - b + + +def mul(a, b): + return a * b diff --git a/benchmarks/e2e/tasks/fizzbuzz/task.toml b/benchmarks/e2e/tasks/fizzbuzz/task.toml new file mode 100644 index 0000000..1663242 --- /dev/null +++ b/benchmarks/e2e/tasks/fizzbuzz/task.toml @@ -0,0 +1,3 @@ +prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time." +max_steps = 12 +timeout_sec = 180 diff --git a/benchmarks/e2e/tasks/fizzbuzz/verify.sh b/benchmarks/e2e/tasks/fizzbuzz/verify.sh new file mode 100644 index 0000000..71952f4 --- /dev/null +++ b/benchmarks/e2e/tasks/fizzbuzz/verify.sh @@ -0,0 +1,8 @@ +set -e +python3 - <<'PY' +import fizzbuzz +assert fizzbuzz.fizzbuzz(3) == "Fizz", fizzbuzz.fizzbuzz(3) +assert fizzbuzz.fizzbuzz(5) == "Buzz", fizzbuzz.fizzbuzz(5) +assert fizzbuzz.fizzbuzz(15) == "FizzBuzz", fizzbuzz.fizzbuzz(15) +assert fizzbuzz.fizzbuzz(7) == "7", fizzbuzz.fizzbuzz(7) +PY diff --git a/benchmarks/e2e/tasks/palindrome/task.toml b/benchmarks/e2e/tasks/palindrome/task.toml new file mode 100644 index 0000000..a9d7c81 --- /dev/null +++ b/benchmarks/e2e/tasks/palindrome/task.toml @@ -0,0 +1,3 @@ +prompt = "Create a file named palindrome.py with a function is_palindrome(s) that returns True if s reads the same forwards and backwards ignoring case, spaces, and punctuation, and False otherwise. Do not print anything at import time." +max_steps = 12 +timeout_sec = 180 diff --git a/benchmarks/e2e/tasks/palindrome/verify.sh b/benchmarks/e2e/tasks/palindrome/verify.sh new file mode 100644 index 0000000..e4b9ebc --- /dev/null +++ b/benchmarks/e2e/tasks/palindrome/verify.sh @@ -0,0 +1,7 @@ +set -e +python3 - <<'PY' +import palindrome +assert palindrome.is_palindrome("Race car") is True +assert palindrome.is_palindrome("A man, a plan, a canal: Panama") is True +assert palindrome.is_palindrome("hello") is False +PY diff --git a/benchmarks/e2e/tasks/subagent-delegation/task.toml b/benchmarks/e2e/tasks/subagent-delegation/task.toml new file mode 100644 index 0000000..509fa48 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/task.toml @@ -0,0 +1,3 @@ +prompt = "This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory." +max_steps = 15 +timeout_sec = 300 diff --git a/benchmarks/e2e/tasks/subagent-delegation/verify.sh b/benchmarks/e2e/tasks/subagent-delegation/verify.sh new file mode 100644 index 0000000..9bedcc3 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/verify.sh @@ -0,0 +1,12 @@ +set -e +# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run` +# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be +# produced by actually reading the three seed files (the prompt mandates doing +# that via the `task` tool). Before sub-agents could run without a parent +# session, the `task` call errored here with "parent session is required". +test -f result.txt +got=$(tr -d '[:space:]' < result.txt) +if [ "$got" != "86" ]; then + echo "result.txt = '$got', want 86" + exit 1 +fi diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt new file mode 100644 index 0000000..0d93535 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt @@ -0,0 +1 @@ +alpha=17 diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt new file mode 100644 index 0000000..53aae3d --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt @@ -0,0 +1 @@ +beta=28 diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt new file mode 100644 index 0000000..c993911 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt @@ -0,0 +1 @@ +gamma=41 diff --git a/cmd/e2ebench/diff.go b/cmd/e2ebench/diff.go new file mode 100644 index 0000000..170e2a7 --- /dev/null +++ b/cmd/e2ebench/diff.go @@ -0,0 +1,658 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "reasonix/internal/shellparse" +) + +type diffOpts struct { + bin, model, repo, base, testCmd, profile string + maxSteps, timeoutSec, attempts int +} + +type testRef struct{ name, pkg string } + +// pinResult records whether one generated test fails when the PR's source is +// reverted (so it pins the change) and, if so, whether it failed by assertion +// (strong: it checks the new behavior) or only by compile error (weak: it just +// references a symbol the PR added). +type pinResult struct { + testRef + pins bool + byAssertion bool +} + +// runDiff asks the agent to write tests covering what the PR changed, grades +// them against the repo's own tests, and — because the agent is stochastic — +// retries up to o.attempts times until a run passes, keeping the best result. +func runDiff(o diffOpts) string { + srcFiles := changedGoFiles(o.repo, o.base, false) + if len(srcFiles) == 0 { + profile := o.profile + if profile == "" { + profile = benchmarkProfileBaseline + } + return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile) + } + pkgs := packagesOf(srcFiles) + prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--"))) + + attempts := o.attempts + if attempts < 1 { + attempts = 1 + } + var best diffReport + made := 0 + for i := 1; i <= attempts; i++ { + if i > 1 { + resetTree(o.repo) + } + r := runOnce(o, srcFiles, pkgs, prompt) + made = i + if i == 1 || better(r, best) { + best = r + } + if best.passed { + break // stop at the first passing run; attempts is a retry budget + } + } + best.attempt, best.attempts = made, attempts + return renderDiff(best) +} + +// runOnce does one agent run + grade: generate tests, check they pass on HEAD, +// differential-check each against the reverted source, measure changed-line +// coverage, and confirm the agent didn't break the build anywhere. +func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport { + metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json") + _ = os.Remove(metricsPath) + defer os.Remove(metricsPath) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second) + defer cancel() + + args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)} + if o.model != "" { + args = append(args, "--model", o.model) + } + args = appendBenchmarkProfileArgs(args, o.profile) + args = append(args, prompt) + cmd := exec.CommandContext(ctx, o.bin, args...) + cmd.Dir = o.repo + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout + runErr := cmd.Run() + + // The agent's new files are untracked, so `git diff HEAD` would miss them; + // intent-to-add surfaces them as additions without committing. + _ = exec.Command("git", "-C", o.repo, "add", "-AN").Run() + + m, _ := readMetrics(metricsPath) + testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go") + refs := parseNewTests(testDiff) + sourceTouched := len(changedGoFilesWorktree(o.repo, false)) + testsPass, testOut := runTests(o.repo, o.testCmd, pkgs) + + var pins []pinResult + var mut mutationResult + covered, coverTotal := 0, 0 + if len(refs) > 0 && testsPass { + covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles) + pins = differentialPerTest(o.repo, o.base, srcFiles, refs) + mut = runMutation(o.repo, o.base, srcFiles, refs) + } + buildOK, buildOut := goBuildAll(o.repo) + + passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0 + return diffReport{ + srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff), + newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass, + pins: pins, mut: mut, covered: covered, coverTotal: coverTotal, + buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut), + passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff, + } +} + +// better reports whether candidate a is a stronger result than b: a pass beats a +// fail, then more assertion-pins, then more pins, then higher changed-line +// coverage. +func better(a, b diffReport) bool { + if a.passed != b.passed { + return a.passed + } + if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y { + return x > y + } + if x, y := countPins(a.pins), countPins(b.pins); x != y { + return x > y + } + if a.mut.caught != b.mut.caught { + return a.mut.caught > b.mut.caught + } + return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal) +} + +func ratio(n, d int) float64 { + if d == 0 { + return 0 + } + return float64(n) / float64(d) +} + +// resetTree restores the PR-head tree between attempts, dropping the previous +// attempt's generated tests but keeping the provider config the workflow wrote. +func resetTree(repo string) { + _ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run() + _ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run() +} + +func goBuildAll(repo string) (bool, string) { + cmd := exec.Command("go", "build", "./...") + cmd.Dir = repo + cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs + out, err := cmd.CombinedOutput() + return err == nil, string(out) +} + +func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string { + var b strings.Builder + b.WriteString("You are in a Go repository. This pull request changed these source files:\n") + for _, f := range srcFiles { + fmt.Fprintf(&b, " - %s\n", f) + } + b.WriteString("\nUnified diff of the change:\n```diff\n") + b.WriteString(diffText) + b.WriteString("\n```\n\n") + b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ") + b.WriteString("Add them to the appropriate *_test.go files in the same packages (") + b.WriteString(strings.Join(pkgs, ", ")) + b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ") + b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ") + b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.") + return b.String() +} + +type diffReport struct { + srcFiles, pkgs []string + addedTestLines int + newTests []testRef + sourceTouched int + testsPass bool + pins []pinResult + mut mutationResult + covered, coverTotal int + buildOK bool + buildOut string + failing []string + passed bool + profile string + attempt, attempts int + m runMetrics + runErr error + testOut string + testDiff string +} + +func renderDiff(r diffReport) string { + var b strings.Builder + result := "❌ fail" + if r.passed { + result = "✅ pass" + } + profile := r.profile + if profile == "" { + profile = benchmarkProfileBaseline + } + fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile) + fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs)) + + pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins) + fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n") + fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests)) + fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines) + fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass)) + fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r)) + if pinned > 0 { + fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert) + } + fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r)) + fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r)) + fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK)) + fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched) + fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens)) + fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens)) + fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps) + fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost) + if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 { + fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes) + fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines) + fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall) + fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries) + if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 { + fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs) + } + } + if len(r.failing) > 0 { + fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `")) + } + if r.attempts > 1 { + status := "none passed" + if r.passed { + status = "passed" + } + fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status) + } + + fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", ")) + if r.attempts <= 1 { + fmt.Fprintf(&b, "\nSingle stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.\n") + } + if !r.buildOK && strings.TrimSpace(r.buildOut) != "" { + fmt.Fprintf(&b, "\n
go build ./... output (tail)\n\n```\n%s\n```\n
\n", tail(r.buildOut, 40)) + } + if r.sourceTouched > 0 { + fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched) + } + + if len(r.pins) > 0 { + fmt.Fprintf(&b, "\n
Per-test differential\n\n| Test | Package | Pins the change? |\n|---|---|---|\n") + for _, p := range r.pins { + fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p)) + } + fmt.Fprintf(&b, "\n
\n") + } + if strings.TrimSpace(r.testDiff) != "" { + fmt.Fprintf(&b, "\n
Generated tests (review the assertions)\n\n```diff\n%s\n```\n
\n", truncateFor(r.testDiff, 20000)) + } + if !r.testsPass && strings.TrimSpace(r.testOut) != "" { + fmt.Fprintf(&b, "\n
go test output (tail)\n\n```\n%s\n```\n
\n", tail(r.testOut, 60)) + } + if r.runErr != nil { + fmt.Fprintf(&b, "\nagent run note: %v\n", r.runErr) + } + fmt.Fprintf(&b, "\nPass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.\n") + return b.String() +} + +func differentialCell(r diffReport) string { + if !(len(r.newTests) > 0 && r.testsPass) { + return "n/a (tests not green)" + } + return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins)) +} + +func coverageCell(r diffReport) string { + if r.coverTotal == 0 { + return "n/a" + } + return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal) +} + +func mutationCell(r diffReport) string { + if r.mut.total == 0 { + return "n/a" + } + cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total)) + if len(r.mut.survivors) > 0 { + cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `")) + } + return cell +} + +func pinCell(p pinResult) string { + switch { + case p.pins && p.byAssertion: + return "✅ by assertion" + case p.pins: + return "⚠️ by compile only" + default: + return "❌ no (passes on old code)" + } +} + +// differentialPerTest reverts the PR's changed source to base (deleting files +// new in the PR), runs each generated test on its own against the old code, and +// restores the source. A test that fails on the old code pins the change. +func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult { + for _, f := range srcFiles { + if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil { + _ = os.Remove(filepath.Join(repo, filepath.FromSlash(f))) + } + } + // Restore source even on panic; a tree left on `base` would mask the PR for later steps. + restored := false + defer func() { + if restored { + return + } + for _, f := range srcFiles { + _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run() + } + }() + + out := make([]pinResult, 0, len(refs)) + for _, r := range refs { + cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg) + cmd.Dir = repo + cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test + raw, err := cmd.CombinedOutput() + out = append(out, pinResult{ + testRef: r, + pins: err != nil, + byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name), + }) + } + for _, f := range srcFiles { + _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run() + } + restored = true + return out +} + +// changedLineCoverage runs the affected packages with a coverage profile and +// reports how many of the PR's changed source statement-lines the (new+existing) +// tests actually execute. covered/total are over changed lines that fall inside +// a coverage block; lines that aren't statements are ignored. +func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) { + profile := filepath.Join(repo, ".e2e-cover.out") + defer os.Remove(profile) + args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...) + cmd := exec.Command("go", args...) + cmd.Dir = repo + _ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran + + blocks := parseCoverProfile(repo, profile) + for file, lines := range changedLineSet(repo, base, srcFiles) { + fileBlocks := blocks[file] + for ln := range lines { + for _, blk := range fileBlocks { + if ln >= blk.start && ln <= blk.end { + total++ + if blk.count > 0 { + covered++ + } + break + } + } + } + } + return covered, total +} + +type coverBlock struct { + start, end, count int +} + +// parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path +// (the profile uses module-qualified paths; we match by repo-relative suffix). +func parseCoverProfile(repo, path string) map[string][]coverBlock { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + out := map[string][]coverBlock{} + for _, ln := range strings.Split(string(data), "\n") { + if ln == "" || strings.HasPrefix(ln, "mode:") { + continue + } + colon := strings.LastIndexByte(ln, ':') + if colon < 0 { + continue + } + modPath, rest := ln[:colon], ln[colon+1:] + var sl, sc, el, ec, nstmt, count int + if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil { + continue + } + rel := repoRelFromModulePath(modPath) + out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count}) + } + return out +} + +// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into +// "internal/agent/foo.go" by dropping the first path element (the module root). +func repoRelFromModulePath(p string) string { + // Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path. + prefix := "reasonix/" + if strings.HasPrefix(p, prefix) { + return p[len(prefix):] + } + if i := strings.IndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +// changedLineSet returns, per repo-relative source file, the set of new line +// numbers the PR added or changed (from a zero-context diff). +func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool { + args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...) + diff := gitOut(repo, args...) + out := map[string]map[int]bool{} + file := "" + newLine := 0 + for _, ln := range strings.Split(diff, "\n") { + // '-' (deletion) lines are intentionally unhandled: they don't advance the + // new-side line counter, so they fall through with no case. + switch { + case strings.HasPrefix(ln, "+++ b/"): + file = strings.TrimPrefix(ln, "+++ b/") + out[file] = map[int]bool{} + case strings.HasPrefix(ln, "@@"): + // @@ -a,b +c,d @@ — start collecting at new-side line c. + // Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed. + if plus := strings.Index(ln, "+"); plus >= 0 { + num := ln[plus+1:] + end := len(num) + for i := 0; i < len(num); i++ { + if num[i] < '0' || num[i] > '9' { + end = i + break + } + } + _, _ = fmt.Sscanf(num[:end], "%d", &newLine) + } + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): + if file != "" { + out[file][newLine] = true + } + newLine++ + } + } + return out +} + +func countPins(ps []pinResult) int { + n := 0 + for _, p := range ps { + if p.pins { + n++ + } + } + return n +} + +func countAssertionPins(ps []pinResult) int { + n := 0 + for _, p := range ps { + if p.pins && p.byAssertion { + n++ + } + } + return n +} + +// parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/ +// Benchmark functions the agent added, each tagged with its package directory. +func parseNewTests(diff string) []testRef { + var refs []testRef + pkg := "" + for _, ln := range strings.Split(diff, "\n") { + if strings.HasPrefix(ln, "+++ b/") { + pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/"))) + continue + } + if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") { + continue + } + body := strings.TrimSpace(ln[1:]) + if !strings.HasPrefix(body, "func ") { + continue + } + sig := strings.TrimPrefix(body, "func ") + // Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name. + var name string + if sig[0] == '(' { + close := strings.IndexByte(sig, ')') + if close < 0 { + continue + } + rest := strings.TrimSpace(sig[close+1:]) + methodParen := strings.IndexByte(rest, '(') + if methodParen <= 0 { + continue + } + name = rest[:methodParen] + } else { + funcParen := strings.IndexByte(sig, '(') + if funcParen <= 0 { + continue + } + name = sig[:funcParen] + } + if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") { + refs = append(refs, testRef{name: name, pkg: pkg}) + } + } + return refs +} + +func countAdded(diff string) int { + n := 0 + for _, ln := range strings.Split(diff, "\n") { + if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") { + n++ + } + } + return n +} + +// failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines. +func failingTestNames(out string) []string { + var names []string + seen := map[string]bool{} + for _, ln := range strings.Split(out, "\n") { + ln = strings.TrimSpace(ln) + if !strings.HasPrefix(ln, "--- FAIL:") { + continue + } + rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:"))) + if len(rest) > 0 && !seen[rest[0]] { + seen[rest[0]] = true + names = append(names, rest[0]) + } + } + return names +} + +func runTests(repo, testCmd string, pkgs []string) (bool, string) { + test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true}) + if err != nil { + return false, "invalid test command: " + err.Error() + } + fields := test.Argv + if len(fields) == 0 { + fields = []string{"go", "test"} + } + args := append(fields[1:], pkgs...) + cmd := exec.Command(fields[0], args...) + cmd.Dir = repo + if len(test.Env) > 0 { + cmd.Env = append(os.Environ(), test.Env...) + } + cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs + out, err := cmd.CombinedOutput() + return err == nil, string(out) +} + +// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go +// when includeTests is false (we want the source under test). +func changedGoFiles(repo, base string, includeTests bool) []string { + return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests) +} + +func changedGoFilesWorktree(repo string, includeTests bool) []string { + return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests) +} + +func filterGo(out string, includeTests bool) []string { + var keep []string + for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) { + if strings.HasSuffix(f, "_test.go") && !includeTests { + continue + } + keep = append(keep, f) + } + sort.Strings(keep) + return keep +} + +func packagesOf(files []string) []string { + seen := map[string]bool{} + var pkgs []string + for _, f := range files { + dir := "./" + filepath.ToSlash(filepath.Dir(f)) + if !seen[dir] { + seen[dir] = true + pkgs = append(pkgs, dir) + } + } + sort.Strings(pkgs) + return pkgs +} + +func gitOut(repo string, args ...string) string { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, _ := cmd.Output() + return string(out) +} + +func truncate(s string) string { return truncateFor(s, 12000) } + +func truncateFor(s string, max int) string { + if max <= 0 || len(s) <= max { + return s + } + // Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune. + cut := max + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "\n…(truncated)…" +} + +func tail(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +func passFail(ok bool) string { + if ok { + return "pass" + } + return "fail" +} diff --git a/cmd/e2ebench/diff_test.go b/cmd/e2ebench/diff_test.go new file mode 100644 index 0000000..6d98b05 --- /dev/null +++ b/cmd/e2ebench/diff_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) { + repo := t.TempDir() + writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n") + writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest + +import ( + "os" + "testing" +) + +func TestEnv(t *testing.T) { + if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" { + t.Fatalf("REASONIX_E2E_ENV = %q", got) + } +} +`) + + ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."}) + if !ok { + t.Fatalf("runTests failed:\n%s", out) + } +} + +func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) { + ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."}) + if ok { + t.Fatal("runTests accepted dynamic env assignment") + } + if !strings.Contains(out, "invalid test command: shell expansion") { + t.Fatalf("output = %q, want shell expansion rejection", out) + } +} + +func writeE2EFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/cmd/e2ebench/main.go b/cmd/e2ebench/main.go new file mode 100644 index 0000000..1cd63f0 --- /dev/null +++ b/cmd/e2ebench/main.go @@ -0,0 +1,430 @@ +// e2ebench runs the committed e2e task suite against a real provider and emits a +// markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/BurntSushi/toml" + + fileencoding "reasonix/internal/fileutil/encoding" +) + +type task struct { + ID string + Prompt string `toml:"prompt"` + MaxSteps int `toml:"max_steps"` + TimeoutSec int `toml:"timeout_sec"` + dir string +} + +type runMetrics struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + CacheHitTokens int `json:"cache_hit_tokens"` + CacheMissTokens int `json:"cache_miss_tokens"` + Steps int `json:"steps"` + Cost float64 `json:"cost"` + Currency string `json:"currency"` + Compactions int `json:"compactions"` + + // Optional Delivery capability counters (omitempty for baseline/old metrics). + ReadinessChecks int `json:"readiness_checks,omitempty"` + ReadinessRecoveries int `json:"readiness_recoveries,omitempty"` + CapabilityRoutes int `json:"capability_routes,omitempty"` + CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"` + CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"` + CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"` + CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"` + CapabilityDeclines int `json:"capability_declines,omitempty"` + CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"` + CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"` + CapabilityMCPCall int `json:"capability_mcp_call,omitempty"` + CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"` + CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"` + CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"` +} + +type result struct { + task + runMetrics + Profile string `json:"profile"` + Passed bool + Skipped bool + Note string +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n") + fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name()) + flag.PrintDefaults() + fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n") + fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n") + fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1)) + fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n") + fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1)) + fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n") + fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1)) + } + + mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)") + suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks//)") + bin := flag.String("bin", "reasonix", "path to the reasonix binary") + model := flag.String("model", "", "provider/model name (default: config default)") + profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery") + outMD := flag.String("out", "", "write the markdown report here (default: stdout)") + outJSON := flag.String("json", "", "write the JSON report here (optional)") + budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)") + // diff-mode flags + repo := flag.String("repo", ".", "repo root (diff mode)") + base := flag.String("base", "", "base ref to diff the PR head against (diff mode)") + testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)") + maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task") + timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)") + attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)") + flag.Parse() + profile, err := normalizeBenchmarkProfile(*profileFlag) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + if *mode == "diff" { + report := runDiff(diffOpts{ + bin: *bin, model: *model, repo: *repo, base: *base, + testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts, + }) + emit(report, *outMD, "") + return + } + + tasks, err := loadTasks(*suite) + if err != nil { + fmt.Fprintln(os.Stderr, "load suite:", err) + os.Exit(1) + } + if len(tasks) == 0 { + dir := filepath.Join(*suite, "tasks") + if _, statErr := os.Stat(dir); statErr != nil { + fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr) + } else { + fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir) + } + os.Exit(1) + } + + var results []result + total := 0 + for _, t := range tasks { + if *budget > 0 && total >= *budget { + results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"}) + continue + } + r := runTask(*bin, *model, profile, t) + total += r.PromptTokens + r.CompletionTokens + results = append(results, r) + } + + report := render(results) + if *outMD != "" { + if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write report:", err) + os.Exit(1) + } + } else { + fmt.Print(report) + } + if *outJSON != "" { + b, err := json.MarshalIndent(results, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "marshal json:", err) + os.Exit(1) + } + if err := os.WriteFile(*outJSON, b, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write json:", err) + os.Exit(1) + } + } +} + +func emit(report, outMD, _ string) { + if outMD != "" { + if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write report:", err) + os.Exit(1) + } + return + } + fmt.Print(report) +} + +func loadTasks(suite string) ([]task, error) { + tasksDir := filepath.Join(suite, "tasks") + entries, err := os.ReadDir(tasksDir) + if err != nil { + return nil, err + } + var tasks []task + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(tasksDir, e.Name()) + var t task + data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml")) + if err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + if _, err := toml.Decode(string(data), &t); err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + t.ID = e.Name() + t.dir = dir + if t.TimeoutSec == 0 { + t.TimeoutSec = 240 + } + tasks = append(tasks, t) + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID }) + return tasks, nil +} + +// runTask copies the task's seed workdir into a temp dir, runs the agent there, +// then drops in verify.sh and runs it as the grader. The grader is added only +// after the run so the agent can't read the answer key. +func runTask(bin, model, profile string, t task) result { + r := result{task: t, Profile: profile} + + work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-") + if err != nil { + r.Note = "mktemp: " + err.Error() + return r + } + defer os.RemoveAll(work) + + if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) { + if err := copyDir(seed, work); err != nil { + r.Note = "copy seed: " + err.Error() + return r + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second) + defer cancel() + + metricsPath := filepath.Join(work, ".run-metrics.json") + args := []string{"run", "--metrics", metricsPath} + if model != "" { + args = append(args, "--model", model) + } + if t.MaxSteps > 0 { + args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps)) + } + args = appendBenchmarkProfileArgs(args, profile) + args = append(args, t.Prompt) + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = work + cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report + cmd.Stderr = os.Stderr + cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout + runErr := cmd.Run() + + if m, err := readMetrics(metricsPath); err == nil { + r.runMetrics = m + } + if runErr != nil { + r.Note = "run: " + runErr.Error() + // still grade — a non-zero exit may just be a max-steps notice + } + + r.Passed = grade(work, t.dir) + return r +} + +func grade(work, taskDir string) bool { + verify := filepath.Join(taskDir, "verify.sh") + if !fileExists(verify) { + return false + } + dst := filepath.Join(work, "verify.sh") + if err := copyFile(verify, dst); err != nil { + return false + } + cmd := exec.Command("bash", "verify.sh") + cmd.Dir = work + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() == nil +} + +func render(results []result) string { + var b strings.Builder + passed, ran := 0, 0 + var pTok, cTok, hit, miss, compacts int + var cost float64 + currency := "" + for _, r := range results { + if r.Skipped { + continue + } + ran++ + if r.Passed { + passed++ + } + pTok += r.PromptTokens + cTok += r.CompletionTokens + hit += r.CacheHitTokens + miss += r.CacheMissTokens + compacts += r.Compactions + cost += r.Cost + if r.Currency != "" { + currency = r.Currency + } + } + + profile := benchmarkProfileBaseline + if len(results) > 0 && results[0].Profile != "" { + profile = results[0].Profile + } + fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile) + fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n", + passed, ran, pct(passed, ran), pct(hit, hit+miss), + comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost) + + fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n") + fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n") + for _, r := range results { + switch { + case r.Skipped: + fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID) + default: + res := "❌ fail" + if r.Passed { + res = "✅ pass" + } + fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n", + r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens), + pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens), + r.Compactions, currencySym(r.Currency), r.Cost) + } + } + fmt.Fprintf(&b, "\nReal provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.\n") + + notes := false + for _, r := range results { + if r.Note != "" { + if !notes { + fmt.Fprintf(&b, "\n
Notes\n\n") + notes = true + } + fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note) + } + } + if notes { + fmt.Fprintf(&b, "\n
\n") + } + return b.String() +} + +func pct(n, d int) string { + if d == 0 { + return "n/a" + } + return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d)) +} + +func comma(n int) string { + s := fmt.Sprint(n) + if len(s) <= 3 { + return s + } + var out []byte + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} + +func currencySym(c string) string { + if c == "" { + return "" + } + return c + " " +} + +func readMetrics(path string) (runMetrics, error) { + var m runMetrics + b, err := fileencoding.ReadFileUTF8(path) + if err != nil { + return m, err + } + return m, json.Unmarshal(b, &m) +} + +func dirExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +func fileExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Skip symlinks so a seed link can't leak a file from outside the seed tree. + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(p, target) + }) +} + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + // Mirror the source mode so a seed's read-only / exec bit survives the copy. + return os.Chmod(dst, info.Mode().Perm()) +} diff --git a/cmd/e2ebench/mutation.go b/cmd/e2ebench/mutation.go new file mode 100644 index 0000000..5a214cc --- /dev/null +++ b/cmd/e2ebench/mutation.go @@ -0,0 +1,143 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const maxMutants = 15 + +type mutationResult struct { + caught, total int + survivors []string +} + +// runMutation gives a behavioral signal that the differential can't for additive +// PRs: it replaces each changed function's body with a zero-value return (which +// compiles for any signature via *new(T)), runs only the agent's new tests for +// that package, and records whether they catch the mutation. A caught mutant +// means a test actually asserts that function's output; a survivor means the new +// tests don't check it. +func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult { + changed := changedLineSet(repo, base, srcFiles) + byPkg := map[string][]string{} + for _, r := range refs { + byPkg[r.pkg] = append(byPkg[r.pkg], r.name) + } + + var res mutationResult + for _, file := range srcFiles { + if res.total >= maxMutants { + break + } + pkg := "./" + filepath.ToSlash(filepath.Dir(file)) + tests := byPkg[pkg] + if len(tests) == 0 { + continue // no new tests to attribute a catch to + } + runRe := "^(" + strings.Join(tests, "|") + ")$" + + abs := filepath.Join(repo, filepath.FromSlash(file)) + srcB, err := os.ReadFile(abs) + if err != nil { + continue + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, abs, srcB, 0) + if err != nil { + continue + } + src := string(srcB) + for _, fd := range changedFuncs(fset, f, changed[file]) { + if res.total >= maxMutants { + break + } + res.total++ + lb := fset.Position(fd.Body.Lbrace).Offset + rb := fset.Position(fd.Body.Rbrace).Offset + mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:] + if os.WriteFile(abs, []byte(mutated), 0o644) != nil { + res.total-- + continue + } + cmd := exec.Command("go", "test", "-run", runRe, pkg) + cmd.Dir = repo + cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test + // Restore source even on panic; a file left mutated would corrupt the next mutant. + restored := false + defer func() { + if !restored { + _ = os.WriteFile(abs, srcB, 0o644) + } + }() + caught := cmd.Run() != nil + _ = os.WriteFile(abs, srcB, 0o644) + restored = true + if caught { + res.caught++ + } else { + res.survivors = append(res.survivors, fd.Name.Name) + } + } + } + return res +} + +// changedFuncs returns the funcs in f whose line range overlaps a changed line. +// main/init and nil-named decls are skipped (no meaningful return to mutate). +func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl { + var out []*ast.FuncDecl + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Body == nil || fd.Name == nil { + continue + } + name := fd.Name.Name + if name == "main" || name == "init" { + continue + } + start := fset.Position(fd.Pos()).Line + end := fset.Position(fd.End()).Line + for ln := range lines { + if ln >= start && ln <= end { + out = append(out, fd) + break + } + } + } + return out +} + +// mutantBody returns a replacement body that returns the zero value for each +// result. *new(T) is the zero value of any type T, so this compiles for every +// signature without naming the results or knowing their concrete types. +func mutantBody(fset *token.FileSet, results *ast.FieldList) string { + if results == nil || len(results.List) == 0 { + return "{\n}" + } + var rets []string + for _, field := range results.List { + t := "*new(" + printType(fset, field.Type) + ")" + n := len(field.Names) + if n == 0 { + n = 1 + } + for i := 0; i < n; i++ { + rets = append(rets, t) + } + } + return "{\n\treturn " + strings.Join(rets, ", ") + "\n}" +} + +func printType(fset *token.FileSet, e ast.Expr) string { + var b strings.Builder + _ = printer.Fprint(&b, fset, e) + return b.String() +} diff --git a/cmd/e2ebench/profile.go b/cmd/e2ebench/profile.go new file mode 100644 index 0000000..e3b5808 --- /dev/null +++ b/cmd/e2ebench/profile.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "strings" +) + +const ( + benchmarkProfileBaseline = "baseline" + benchmarkProfileDelivery = "delivery" +) + +func normalizeBenchmarkProfile(profile string) (string, error) { + switch strings.ToLower(strings.TrimSpace(profile)) { + case "", benchmarkProfileBaseline: + return benchmarkProfileBaseline, nil + case benchmarkProfileDelivery: + return benchmarkProfileDelivery, nil + default: + return "", fmt.Errorf("unknown benchmark profile %q (want baseline or delivery)", profile) + } +} + +func appendBenchmarkProfileArgs(args []string, profile string) []string { + if profile == benchmarkProfileDelivery { + return append(args, "--profile", "delivery") + } + return args +} diff --git a/cmd/e2ebench/profile_test.go b/cmd/e2ebench/profile_test.go new file mode 100644 index 0000000..bd32f00 --- /dev/null +++ b/cmd/e2ebench/profile_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) { + args := []string{"run", "fix the bug"} + if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) { + t.Fatalf("baseline args changed: %v", got) + } +} + +func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) { + args := []string{"run"} + got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery) + want := []string{"run", "--profile", "delivery"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("delivery args = %v, want %v", got, want) + } +} + +func TestNormalizeBenchmarkProfile(t *testing.T) { + for _, input := range []string{"", "baseline", " BASELINE "} { + if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline { + t.Fatalf("normalize(%q) = %q, %v", input, got, err) + } + } + if got, err := normalizeBenchmarkProfile("delivery"); err != nil || got != benchmarkProfileDelivery { + t.Fatalf("normalize(delivery) = %q, %v", got, err) + } + if _, err := normalizeBenchmarkProfile("fast"); err == nil { + t.Fatal("unknown profile should fail") + } +} diff --git a/cmd/reasonix-plugin-example/main.go b/cmd/reasonix-plugin-example/main.go new file mode 100644 index 0000000..3d900a6 --- /dev/null +++ b/cmd/reasonix-plugin-example/main.go @@ -0,0 +1,349 @@ +// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio +// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to +// document the contract end-to-end (the protocol the internal/plugin client +// drives) and to give users a working example to copy. +// +// Wire it up in reasonix.toml: +// +// [[plugins]] +// name = "example" +// command = "reasonix-plugin-example" +// +// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount", +// its prompt as the "/mcp__example__review" slash command, and its resource as +// the "@example:doc://style-guide" reference. +// +// Protocol, one JSON object per line: +// - initialize → {protocolVersion, capabilities, serverInfo} +// - notifications/initialized (notification, no id) → ignored +// - tools/list → {tools: [{name, description, inputSchema, annotations}]} +// - tools/call {name, arguments} → {content: [{type:"text", text}], isError} +// - prompts/list → {prompts: [{name, description, arguments}]} +// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]} +// - resources/list → {resources: [{uri, name, description, mimeType}]} +// - resources/read {uri} → {contents: [{uri, mimeType, text}]} +// +// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is +// reserved for JSON-RPC so it must never carry stray prose. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "unicode/utf8" +) + +// version is overridable via -ldflags "-X main.version=...". Reported in +// initialize's serverInfo so reasonix (and humans) can see which build is running. +var version = "dev" + +func main() { + log.SetPrefix("reasonix-plugin-example: ") + log.SetFlags(0) + if err := serve(os.Stdin, os.Stdout); err != nil { + log.Fatal(err) + } +} + +// --- JSON-RPC framing --- + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +const ( + protocolVersion = "2024-11-05" + codeMethodNotFound = -32601 + codeInvalidParams = -32602 +) + +// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the +// pipe / is shutting down). Each line is one JSON-RPC message. +func serve(in *os.File, out *os.File) error { + r := bufio.NewReader(in) + w := bufio.NewWriter(out) + defer w.Flush() + + for { + line, err := r.ReadBytes('\n') + if len(line) > 0 { + if rerr := handleLine(line, w); rerr != nil { + return rerr + } + if ferr := w.Flush(); ferr != nil { + return ferr + } + } + if err != nil { + return nil // EOF or pipe closed: clean shutdown + } + } +} + +func handleLine(line []byte, w *bufio.Writer) error { + line = trimSpace(line) + if len(line) == 0 { + return nil + } + var req request + if err := json.Unmarshal(line, &req); err != nil { + log.Printf("skipping unparseable line: %v", err) + return nil + } + if req.ID == nil { + return nil // notification (e.g. notifications/initialized): no reply + } + + resp := response{JSONRPC: "2.0", ID: *req.ID} + switch req.Method { + case "initialize": + resp.Result = map[string]any{ + "protocolVersion": protocolVersion, + "capabilities": map[string]any{ + "tools": map[string]any{}, + "prompts": map[string]any{}, + "resources": map[string]any{}, + }, + "serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version}, + } + case "tools/list": + resp.Result = map[string]any{"tools": toolList()} + case "tools/call": + resp.Result, resp.Error = callTool(req.Params) + case "prompts/list": + resp.Result = map[string]any{"prompts": promptList()} + case "prompts/get": + resp.Result, resp.Error = getPrompt(req.Params) + case "resources/list": + resp.Result = map[string]any{"resources": resourceList()} + case "resources/read": + resp.Result, resp.Error = readResource(req.Params) + default: + resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method} + } + + b, err := json.Marshal(resp) + if err != nil { + return fmt.Errorf("marshal response: %w", err) + } + if _, err := w.Write(append(b, '\n')); err != nil { + return err + } + return nil +} + +// --- tools --- + +// toolDef is one exposed tool: its advertised metadata plus the handler. run +// returns the text result, or an error which becomes an isError tool result the +// model can read and adapt to. +type toolDef struct { + name string + description string + schema map[string]any + readOnly bool + run func(args map[string]any) (string, error) +} + +// tools is the registry. Both demo tools are read-only and declare it via the +// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the +// permission layer) auto-allows them without prompting. +var tools = []toolDef{ + { + name: "echo", + description: "Echo the given text back. The simplest possible proof the plugin round-trip works.", + readOnly: true, + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string", "description": "Text to echo back"}, + }, + "required": []string{"text"}, + }, + run: func(args map[string]any) (string, error) { + text, ok := args["text"].(string) + if !ok { + return "", fmt.Errorf("argument 'text' must be a string") + } + return text, nil + }, + }, + { + name: "wordcount", + description: "Count the lines, words, and bytes of the given text (like wc).", + readOnly: true, + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string", "description": "Text to measure"}, + }, + "required": []string{"text"}, + }, + run: func(args map[string]any) (string, error) { + text, ok := args["text"].(string) + if !ok { + return "", fmt.Errorf("argument 'text' must be a string") + } + return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d", + countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil + }, + }, +} + +func toolList() []map[string]any { + out := make([]map[string]any, 0, len(tools)) + for _, t := range tools { + out = append(out, map[string]any{ + "name": t.name, + "description": t.description, + "inputSchema": t.schema, + "annotations": map[string]any{ + "readOnlyHint": t.readOnly, + "title": t.name, + }, + }) + } + return out +} + +// callTool dispatches a tools/call. A handler error is reported as an isError +// content result (in-band, so the model sees it) rather than a JSON-RPC error; +// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown +// tool). +func callTool(params json.RawMessage) (any, *rpcError) { + var p struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} + } + for _, t := range tools { + if t.name != p.Name { + continue + } + text, err := t.run(p.Arguments) + if err != nil { + return textResult(err.Error(), true), nil + } + return textResult(text, false), nil + } + return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name} +} + +func textResult(text string, isError bool) map[string]any { + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": text}}, + "isError": isError, + } +} + +// --- prompts --- + +// promptList advertises the server's prompts. They surface in reasonix as +// /mcp__example__ slash commands. +func promptList() []map[string]any { + return []map[string]any{{ + "name": "review", + "description": "Draft a focused code-review request for a file.", + "arguments": []map[string]any{ + {"name": "path", "description": "File to review", "required": true}, + }, + }} +} + +// getPrompt renders a prompt into MCP messages. The returned text becomes the +// next user turn in reasonix, so it's phrased as an instruction to the model. +func getPrompt(params json.RawMessage) (any, *rpcError) { + var p struct { + Name string `json:"name"` + Arguments map[string]string `json:"arguments"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} + } + if p.Name != "review" { + return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name} + } + path := p.Arguments["path"] + if path == "" { + path = "the current file" + } + text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path) + return map[string]any{ + "description": "Code review request", + "messages": []map[string]any{ + {"role": "user", "content": map[string]any{"type": "text", "text": text}}, + }, + }, nil +} + +// --- resources --- + +// resourceContents is the demo resource store, keyed by uri. +var resourceContents = map[string]string{ + "doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.", +} + +func resourceList() []map[string]any { + return []map[string]any{{ + "uri": "doc://style-guide", + "name": "Style guide", + "description": "The project's coding style notes.", + "mimeType": "text/plain", + }} +} + +func readResource(params json.RawMessage) (any, *rpcError) { + var p struct { + URI string `json:"uri"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} + } + text, ok := resourceContents[p.URI] + if !ok { + return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI} + } + return map[string]any{ + "contents": []map[string]any{ + {"uri": p.URI, "mimeType": "text/plain", "text": text}, + }, + }, nil +} + +// countLines counts newline-separated lines, counting a final unterminated line. +func countLines(s string) int { + if s == "" { + return 0 + } + n := strings.Count(s, "\n") + if !strings.HasSuffix(s, "\n") { + n++ + } + return n +} + +// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes. +func trimSpace(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} diff --git a/cmd/reasonix/main.go b/cmd/reasonix/main.go new file mode 100644 index 0000000..8bc4966 --- /dev/null +++ b/cmd/reasonix/main.go @@ -0,0 +1,20 @@ +// Command reasonix is a config- and plugin-driven coding agent CLI. +package main + +import ( + "os" + + "reasonix/internal/cli" + + // Blank imports wire compile-time built-ins into their registries. + _ "reasonix/internal/provider/anthropic" + _ "reasonix/internal/provider/openai" + _ "reasonix/internal/tool/builtin" +) + +// version is injected at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + os.Exit(cli.Run(os.Args[1:], version)) +} diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..dc78f70 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,45 @@ +# Wails build assets/output — mostly auto-generated (plists, manifests, compiled +# binary); ignore them but keep the committed designed app icon source and PNG. +/build/* +!/build/appicon.png +!/build/appicon.svg + +# Commit only our customized per-user NSIS installer template. wails_tools.nsh and +# everything else under build/ are regenerated by `wails build`, so stay ignored. +# (Each `!` un-ignore needs its parent dir un-ignored first.) +!/build/windows +/build/windows/* +!/build/windows/installer +/build/windows/installer/* +!/build/windows/installer/project.nsi + +# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh +# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT +# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first. +!/build/darwin +/build/darwin/* +!/build/darwin/entitlements.plist +!/build/darwin/icon.icns + +# Commit the hand-written Linux .deb packaging inputs and app icon assets. +# `wails build` does not generate these, so they must live in git. The .deb is built +# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first. +!/build/linux +/build/linux/* +!/build/linux/nfpm.yaml +!/build/linux/reasonix.desktop +!/build/linux/icons +!/build/linux/icons/** + +# Go test binaries +*.test + +# Local config symlinks for `wails dev` (point at the repo-root reasonix.toml/.env +# so the desktop app resolves the same config/keys the CLI does). Never commit. +/reasonix.toml +/.env + +# `go build` binary produced in-tree (module reasonix/desktop → "desktop") +/desktop +# Wails dependency-change cache +/frontend/package.json.md5 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..a7f27ce --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,283 @@ +# Reasonix Desktop (Wails shell) + +A native desktop window around the Reasonix Go kernel. The same +transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE +server is bound **directly** to a React webview — Go methods in, typed events +out, no HTTP hop. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ webview (React + TS, Vite) │ +│ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │ +│ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│ +└───────────────▲───────────────────────────┬─────────────────┘ + bound methods runtime.EventsEmit +┌───────────────┴───────────────────────────▼─────────────────┐ +│ desktop/app.go App (bound) + eventSink (event.Sink) │ +│ desktop/main.go Wails options, window, embed frontend/dist │ +└───────────────▲───────────────────────────┬─────────────────┘ + commands │ │ typed event stream +┌───────────────┴────────────────────────────▼────────────────┐ +│ internal/boot.Build → internal/control.Controller (kernel) │ +│ (same assembly the CLI uses: providers, tools, gate, …) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Why a nested module + +`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix => +../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's +`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build / +vet / test ./...` skip this directory, while the import path stays under +`reasonix/` so it can still import the `reasonix/internal/*` kernel. + +## Prerequisites + +- Go (matches the parent module). +- Node + **pnpm** (`npm i -g pnpm`). +- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest` +- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2** + runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links + against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu + 24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run + `wails doctor` to verify. + +## Develop + +```sh +cd desktop +wails dev # hot-reloads Go + frontend (Vite dev server) +``` + +Frontend-only iteration without the Go side: + +```sh +cd desktop/frontend +pnpm install +pnpm dev # opens in a plain browser; bridge.ts uses the dev mock +``` + +In a plain browser the native bindings are absent, so `bridge.ts` falls back to a +**mock** that streams a canned turn (text + one `edit_file` tool call) through the +exact same event contract — so layout, streaming, markdown, tool cards, and the +diff seam can all be built without rebuilding Go. + +## Test + +The desktop package is a nested Go module, so parent `go test ./...` does not run +it. Use the full lane before merging desktop changes, and the short lane for fast +local feedback: + +```sh +make desktop-test # cd desktop && go test . +make desktop-test-short # skips slow desktop integration/e2e checks +``` + +To find the next bottleneck, rank individual test cases from the JSON stream: + +```sh +make desktop-test-times +# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py +``` + +### Frontend UI review checklist + +For anchored menus, dropdowns, tooltips, and other portaled UI, review both the +component code and the CSS positioning contract: + +- If a component uses `createPortal` plus `getBoundingClientRect()`, it must + handle scrollable ancestors, window resize, and `visualViewport` changes. +- Add a focused regression test when changing shared positioning primitives such + as `AnchoredPopover`, not only the specific menu that exposed the bug. +- Exercise at least one scrollable container path, such as Settings content, when + manually checking dropdown or popover changes. + +## Build + +```sh +cd desktop +wails build # → build/bin/Reasonix(.app/.exe) +``` + +**Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no +`webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1. + +```sh +wails build -tags webkit2_41 +wails dev -tags webkit2_41 # same tag for hot-reload +``` + +Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`. + +`frontend/dist` is generated by the build (it's git-ignored except for a +`.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh +checkout). A bare `go build` without a prior `pnpm build` produces a blank window. + +## Releases & auto-update + +Desktop releases ride their own tag namespace, `desktop-v` (plain `v*` +tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`, +which builds on a native runner per platform (Wails can't cross-compile a +CGO/WebKit binary), packages each artifact, signs it with minisign, generates a +`latest.json` manifest, publishes a GitHub release, marks the desktop release as +GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the +current desktop manifest to the matching CLI release for old clients that still +ask GitHub's repository-wide `latest` release for it. +The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs +`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+. + +```sh +git tag desktop-v1.1.0 && git push origin desktop-v1.1.0 +``` + +The app checks `latest.json` on startup (R2 first, then the +`crash.reasonix.io` desktop release gateway) and shows an update banner when a +newer version is published; **Settings → Software update** has a manual check. +The gateway resolves only the desktop `desktop-v*` release line and never uses +GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does +not depend on homepage badge semantics. Self-update behavior by platform: + +- **Linux / Windows** — download, verify the minisign signature, then update in + place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS + installer (no admin rights needed). +- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an + in-place swap would be blocked by Gatekeeper; the banner links to the download + page for a manual update instead. + +### Unsigned builds — first launch + +There are no Apple/Windows code-signing certificates yet, so a downloaded build +trips the OS gatekeepers on first run: + +- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into + Applications. Gatekeeper may then report the app "is damaged" or is from an + unidentified developer; clear the quarantine attribute and open it: + ```sh + xattr -dr com.apple.quarantine /Applications/Reasonix.app + ``` +- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info → + Run anyway*. + +When Developer ID / Authenticode certificates are added, the release workflow's +`HAS_APPLE_CERT` gate flips to the signed path and these steps go away. + +### Verifying a download + +Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig` +signature sits next to each artifact in the release; verify with the +[minisign](https://jedisct1.github.io/minisign/) CLI: + +```sh +minisign -Vm Reasonix-darwin-arm64.zip \ + -P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq +``` + +## Editor seam (Monaco / CodeMirror) + +Code and diff rendering go through two components with stable prop contracts and a +lazy boundary, so a heavy editor stays out of the initial bundle and dropping one +in is a one-line change — no consumer touches: + +| Component | Props | Default impl | Upgrade | +|---|---|---|---| +| `components/CodeViewer.tsx` | `EditorProps` | `editors/PlainCode.tsx` (`
`) | swap the lazy import for `editors/MonacoCode` or `editors/CodeMirrorCode` |
+| `components/DiffView.tsx` | `DiffProps` | `editors/PlainDiff.tsx` (LCS line diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
+
+```sh
+# Monaco
+pnpm add @monaco-editor/react monaco-editor
+# or CodeMirror 6
+pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
+```
+
+Then add `editors/MonacoCode.tsx` (default-export a component taking
+`EditorProps`) and point `CodeViewer.tsx`'s `lazy(() => import(...))` at it.
+`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
+`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
+both seams light up everywhere at once.
+
+Markdown itself is currently minimal (fenced code + plain text). Upgrade path:
+`pnpm add react-markdown remark-gfm` and render in `components/Markdown.tsx`,
+keeping fenced code delegated to `CodeViewer`.
+
+## Multi-platform adaptation
+
+Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses
+the **native** webview per OS, so the rough edges are platform-specific. What's
+handled here, and what to reach for if a target misbehaves:
+
+- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
+  GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
+  is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
+  that cannot access `/dev/dri`. If artifacts persist, launch with
+  `WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
+  the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
+  - **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
+    crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
+    explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
+    Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
+    Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
+    Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
+    or `GDK_BACKEND=x11` (forces XWayland).
+- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
+  setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
+  WebView2 GPU acceleration by default to smoke-test blank-window reports; set
+  `REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
+  off.
+- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
+  marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
+  room for the traffic lights.
+- **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all
+  three webviews honor, so the UI follows the OS theme without native glue.
+- **Fonts / offline** — system font stack only; no web-font fetches, so first paint
+  is instant and identical offline.
+- **First paint** — the window background is set to the dark shell color so there's
+  no white flash before CSS loads (most visible on WebKitGTK).
+
+## Files
+
+```
+desktop/
+  main.go            Wails options, window, embed frontend/dist
+  app.go             App (bound command surface) + eventSink (event.Sink → webview)
+  wire.go            event.Event → JSON wire form (mirrors internal/serve/wire.go)
+  wails.json         Wails project config (pnpm install/build/dev)
+  frontend/
+    src/
+      lib/
+        types.ts         wire contract (mirrors wire.go)
+        bridge.ts        window.go/window.runtime wrapper + browser dev mock
+        useController.ts event-stream reducer + command surface (the hook)
+      components/
+        Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge,
+        Markdown, CodeViewer, DiffView
+        editors/  PlainCode, PlainDiff   ← editor seam impls (swap targets)
+```
+
+## Telemetry
+
+The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
+a random install id (generated locally, tied to nothing), app version, OS,
+arch, and OS version. It exists solely to count active installs. It never
+includes conversations, API keys, file contents, or paths.
+
+Opt out any time: Settings > Updates > "Anonymous usage ping", or set
+`telemetry = false` under `[desktop]` in the global config. Dev builds
+never ping. Crash and performance-pressure reports are separate and only
+ever sent when the user clicks "Send report" on the diagnostic UI.
+
+Aggregate quality metrics are also enabled by default and can be disabled from
+Settings > Updates > "Share aggregate quality metrics", or by setting
+`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
+counts and preference buckets; they never include conversations, prompts, keys,
+paths, base URLs, or file contents.
+
+When Memory v5 is enabled, the same aggregate metrics pipeline may include only
+content-free count/size buckets such as injection on/off, compiled-token bucket,
+IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
+memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
+file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
+is controlled from Settings > General > "Memory v5" and shares the user/global
+`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
+CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
+or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
diff --git a/desktop/app.go b/desktop/app.go
new file mode 100644
index 0000000..8b606d3
--- /dev/null
+++ b/desktop/app.go
@@ -0,0 +1,9047 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"mime"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	goruntime "runtime"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+	"unicode/utf8"
+
+	"github.com/wailsapp/wails/v2/pkg/runtime"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/autoresearch"
+	"reasonix/internal/billing"
+	"reasonix/internal/boot"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/evidence"
+	"reasonix/internal/fileref"
+	fileenc "reasonix/internal/fileutil/encoding"
+	"reasonix/internal/i18n"
+	"reasonix/internal/mcpdiag"
+	"reasonix/internal/memory"
+	"reasonix/internal/notify"
+	"reasonix/internal/plugin"
+	"reasonix/internal/pluginpkg"
+	"reasonix/internal/provider"
+	"reasonix/internal/skill"
+	"reasonix/internal/store"
+	"reasonix/internal/tool"
+)
+
+// eventChannel is the Wails runtime event name the frontend subscribes to for the
+// agent's typed event stream. One channel carries every event kind; the payload's
+// `kind` field discriminates — the desktop analogue of the serve transport's SSE
+// `data:` frames.
+const eventChannel = "agent:event"
+
+const singleInstanceIDPrefix = "com.reasonix.desktop"
+
+// singleInstanceID is used by Wails to route a second desktop launch back to the
+// running instance. It is stable for a given binary path, while allowing a dev
+// build and an installed release at different paths to run side by side.
+func singleInstanceID() string {
+	abs, err := os.Executable()
+	if err != nil {
+		return singleInstanceIDPrefix
+	}
+	if resolved, err := filepath.EvalSymlinks(abs); err == nil {
+		abs = resolved
+	} else if fallback, err := filepath.Abs(abs); err == nil {
+		abs = fallback
+	}
+	sum := sha256.Sum256([]byte(abs))
+	return singleInstanceIDPrefix + "." + hex.EncodeToString(sum[:8])
+}
+
+// PromptHistoryEntry is one user prompt extracted from a session JSONL file.
+// The frontend uses these for ↑/↓ prompt-history navigation.
+type PromptHistoryEntry struct {
+	Text        string `json:"text"`
+	At          int64  `json:"at"` // unix ms
+	SessionPath string `json:"sessionPath"`
+	Turn        int    `json:"turn"`
+}
+
+// PromptHistoryResult is returned as one Wails value. It carries one loaded tape
+// segment plus the cursor needed to keep walking toward older prompts.
+type PromptHistoryResult struct {
+	Entries     []PromptHistoryEntry `json:"entries"`
+	Nonce       string               `json:"nonce"`
+	OlderCursor string               `json:"olderCursor,omitempty"`
+	HasOlder    bool                 `json:"hasOlder"`
+}
+
+// App is the Wails-bound application object: the desktop frontend's command
+// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS
+// bindings. The app manages multiple WorkspaceTabs — each with its own controller
+// scoped to a project workspace — and routes commands to the active tab. Events
+// flow the other way: each tab's controller emits to a tabEventSink that
+// forwards events tagged with tabId to the webview via runtime.EventsEmit.
+type App struct {
+	ctx context.Context
+
+	// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
+	// from bound methods. All bound methods that touch a controller use activeCtrl().
+	mu          sync.RWMutex
+	tabs        map[string]*WorkspaceTab
+	tabOrder    []string
+	activeTabID string
+	readyHook   func()
+
+	// tabsRestored is closed when restoreOrBuildTabs has finished populating
+	// a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
+	// work that inspects "which sessions are open" or persists the tab list
+	// (recovery GC's DeleteSession does both) must wait on it: running against
+	// the pre-restore empty tab map would treat every saved tab's session as
+	// closed and could overwrite desktop-tabs.json with an empty snapshot.
+	tabsRestored chan struct{}
+
+	// projectTreeChangedHook is test-only: set once before any concurrency
+	// starts, then read lock-free from emitProjectTreeChanged (whose callers
+	// may or may not hold a.mu, so it cannot re-lock). Never write it after
+	// startup.
+	projectTreeChangedHook func()
+
+	// singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
+	// one-conversation layout so overlapping navigation cannot remove the tab
+	// another navigation is still activating.
+	singleSurfaceMu sync.Mutex
+
+	// sessionRemovalMu serializes operations that remove visible or detached
+	// session bindings. Those operations may snapshot controllers before
+	// deletion; keep that snapshot outside a.mu, but do not let DeleteSession or
+	// topic/workspace removal trash the same files while it is in flight.
+	sessionRemovalMu sync.Mutex
+
+	// runtimeRebuildMu serializes controller rebuilds (build + swap) across
+	// rebuildSetting, SetModelForTab, and the deferred-rebuild retry loop. Two
+	// concurrent rebuilds of the same tab both pass the tab-identity check at
+	// swap time (the tab pointer is unchanged), so the loser's controller
+	// would replace the winner's and leak it without Close.
+	runtimeRebuildMu sync.Mutex
+
+	// tryRunMu guards tryRunCancel — the cancel handle for the single
+	// in-flight settings-page subagent try run (TrySubagentProfile /
+	// CancelTrySubagentProfile).
+	tryRunMu     sync.Mutex
+	tryRunCancel context.CancelFunc
+
+	// deferredRebuild tracks tabs whose settings were saved but whose runtime
+	// could not refresh because the session lease was held by another process.
+	deferredRebuild deferredRebuildState
+
+	// detachedSessions keeps live session runtimes whose visible tab was closed.
+	// It is process-local by design: shutdown closes every detached controller.
+	detachedSessions map[string]*WorkspaceTab
+
+	// sharedHosts holds one *plugin.Host per workspace root, shared by all
+	// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are
+	// spawned once instead of N times. Lifecycle: first Acquire creates the
+	// host, last Release closes it.
+	sharedHosts   map[string]*sharedPluginHost
+	sharedHostsMu sync.Mutex
+
+	// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.
+	tabsSaveMu             sync.Mutex
+	tabsSaveVersion        uint64 // protected by mu; assigned when collecting a snapshot
+	tabsLastWrittenVersion uint64 // protected by tabsSaveMu
+
+	forceQuit           atomic.Bool
+	backgroundMaximised atomic.Bool
+	trayReady           bool
+	tray                *desktopTray
+	hangWatchdogMu      sync.Mutex
+	hangWatchdogCancel  context.CancelFunc
+
+	mediaTokens *mediaTokenStore
+	botInstalls map[string]*botInstallSession
+	botRuntime  *desktopBotRuntime
+	// botBridge gives the embedded bot gateway a god view over desktop
+	// sessions (/desktop commands). Set once in NewApp before any tab exists,
+	// read-only afterwards, so tabEventSink.Emit reads it without a lock.
+	botBridge *botBridgeHub
+
+	metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
+
+	notificationSenderOnce sync.Once
+	notificationSender     notify.Sender
+
+	runtimeEvents asyncRuntimeEmitter
+
+	// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
+	// stores session order and per-session parsed entries only after that session is
+	// reached by ↑ navigation. See ScanPromptHistory.
+	promptHistoryMu   sync.Mutex
+	promptHistoryTape *promptHistoryTape
+
+	skillRootsMu    sync.Mutex
+	skillRootsCache skillRootsCache
+
+	heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
+}
+
+type skillRootsCache struct {
+	key   string
+	at    time.Time
+	roots []SkillRootView
+}
+
+// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.
+type mediaTokenEntry struct {
+	absPath   string
+	filename  string
+	mime      string
+	kind      string
+	size      int64
+	modTime   time.Time
+	createdAt time.Time
+	expiresAt time.Time
+}
+
+// mediaTokenStore manages temporary tokens that grant access to workspace files
+// through the AssetServer middleware. Tokens expire after a fixed TTL and are
+// capped at a maximum count; creating a new token evicts the oldest entry when
+// the store is full.
+type mediaTokenStore struct {
+	mu    sync.Mutex
+	byTok map[string]*mediaTokenEntry
+	order []string // oldest first
+	maxN  int
+	ttl   time.Duration
+}
+
+const mediaTokenMax = 256
+
+func newMediaTokenStore() *mediaTokenStore {
+	return &mediaTokenStore{
+		byTok: map[string]*mediaTokenEntry{},
+		maxN:  mediaTokenMax,
+		ttl:   10 * time.Minute,
+	}
+}
+
+func (s *mediaTokenStore) cleanupLocked() {
+	now := time.Now()
+	for len(s.order) > 0 {
+		tok := s.order[0]
+		e := s.byTok[tok]
+		if e == nil {
+			s.order = s.order[1:]
+			continue
+		}
+		if !now.Before(e.expiresAt) {
+			delete(s.byTok, tok)
+			s.order = s.order[1:]
+			continue
+		}
+		break
+	}
+	for len(s.order) > s.maxN {
+		oldest := s.order[0]
+		delete(s.byTok, oldest)
+		s.order = s.order[1:]
+	}
+}
+
+func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.cleanupLocked()
+
+	tok := make([]byte, 16)
+	if _, err := rand.Read(tok); err != nil {
+		panic("crypto/rand.Read failed: " + err.Error())
+	}
+	token := hex.EncodeToString(tok)
+
+	now := time.Now()
+	s.byTok[token] = &mediaTokenEntry{
+		absPath:   absPath,
+		filename:  filename,
+		mime:      mime,
+		kind:      kind,
+		size:      size,
+		modTime:   modTime,
+		createdAt: now,
+		expiresAt: now.Add(s.ttl),
+	}
+	s.order = append(s.order, token)
+
+	// Trim oldest if the new token pushed us over the limit.
+	for len(s.order) > s.maxN {
+		oldest := s.order[0]
+		delete(s.byTok, oldest)
+		s.order = s.order[1:]
+	}
+
+	return token
+}
+
+func (s *mediaTokenStore) get(token string) *mediaTokenEntry {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	e := s.byTok[token]
+	if e == nil {
+		return nil
+	}
+	if time.Now().After(e.expiresAt) {
+		delete(s.byTok, token)
+		return nil
+	}
+	return e
+}
+
+func (a *App) ensureMediaTokenStore() *mediaTokenStore {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	if a.mediaTokens == nil {
+		a.mediaTokens = newMediaTokenStore()
+	}
+	return a.mediaTokens
+}
+
+// jsProfilingMiddleware opts every asset response into the JS Self-Profiling
+// document policy so the frontend performance monitor can attach sampled stacks
+// to long-task reports. Chromium WebViews (WebView2) honor it; WebKit ignores
+// both the header and the API, so the frontend degrades to unattributed reports.
+func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Document-Policy", "js-profiling")
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// workspaceMediaMiddleware returns an HTTP middleware that intercepts
+// /__reasonix_workspace_media/{token}/{filename} requests and serves the
+// corresponding workspace file. All other paths pass through to the Wails
+// default asset handler unchanged.
+func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			prefix := "/__reasonix_workspace_media/"
+			if !strings.HasPrefix(r.URL.Path, prefix) {
+				next.ServeHTTP(w, r)
+				return
+			}
+
+			if r.Method != http.MethodGet && r.Method != http.MethodHead {
+				w.WriteHeader(http.StatusMethodNotAllowed)
+				return
+			}
+
+			rest := strings.TrimPrefix(r.URL.Path, prefix)
+			parts := strings.SplitN(rest, "/", 2)
+			if len(parts) == 0 || parts[0] == "" {
+				http.NotFound(w, r)
+				return
+			}
+			token := parts[0]
+
+			entry := a.ensureMediaTokenStore().get(token)
+			if entry == nil {
+				http.NotFound(w, r)
+				return
+			}
+
+			f, err := os.Open(entry.absPath)
+			if err != nil {
+				http.NotFound(w, r)
+				return
+			}
+			defer f.Close()
+
+			w.Header().Set("Content-Type", entry.mime)
+			w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.filename}))
+			w.Header().Set("X-Content-Type-Options", "nosniff")
+			w.Header().Set("Cache-Control", "private, max-age=600")
+			http.ServeContent(w, r, entry.filename, entry.modTime, f)
+		})
+	}
+}
+
+// NewApp constructs the bound object. Tabs are restored in startup from the
+// last session's desktop-tabs.json.
+func NewApp() *App {
+	a := &App{
+		tabs:             map[string]*WorkspaceTab{},
+		detachedSessions: map[string]*WorkspaceTab{},
+		mediaTokens:      newMediaTokenStore(),
+		botInstalls:      map[string]*botInstallSession{},
+		botRuntime:       newDesktopBotRuntime(),
+	}
+	a.botBridge = a.newBotBridge()
+	return a
+}
+
+func (a *App) bootContext() context.Context {
+	if a.ctx != nil {
+		return a.ctx
+	}
+	return context.Background()
+}
+
+// Platform exposes the native OS to the frontend so chrome/layout affordances can
+// stay platform-scoped instead of relying on browser user-agent guesses.
+func (a *App) Platform() string {
+	return goruntime.GOOS
+}
+
+// startup runs once the webview process is up, before the frontend can issue any
+// bound call. It captures the Wails context (needed for EventsEmit), then kicks
+// off the initialization in a background goroutine so the webview loads immediately.
+func (a *App) startup(ctx context.Context) {
+	a.ctx = ctx
+	installSystemQuitHook()
+	a.startTray()
+	a.enableDeferredRebuildRetry()
+
+	if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
+		a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
+		a.recordSettingsMetricsSnapshot(cfg)
+	}
+	a.startMainThreadWatchdog()
+
+	a.heartbeat = newHeartbeatEngine(a)
+	a.heartbeat.Start()
+
+	a.mu.Lock()
+	a.tabsRestored = make(chan struct{})
+	a.mu.Unlock()
+	go a.restoreOrBuildTabs()
+	a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
+	a.goSafe("sendStartupPing", a.sendStartupPing)
+	a.goSafe("flushMetrics", a.flushMetrics)
+	a.goSafe("flushPendingCrash", a.flushPendingCrash)
+	// After restoreOrBuildTabs is launched: the GC's first sweep waits on
+	// tabsRestored so it never observes the pre-restore empty tab map.
+	a.startRecoveryGC()
+}
+
+func (a *App) beforeClose(ctx context.Context) bool {
+	if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
+		return false
+	}
+	cfg, _, err := a.loadDesktopUserConfigForView()
+	if err != nil {
+		cfg = config.LoadForEdit(config.UserConfigPath())
+	}
+	if cfg.DesktopCloseBehavior() == "background" {
+		if !a.backgroundCloseHasRestorePath() {
+			return false
+		}
+		a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))
+		a.saveWindowStateSync()
+		a.snapshotAllTabs()
+		hideForBackground(ctx)
+		return true
+	}
+	return false
+}
+
+const backgroundCloseTrayReadyTimeout = 500 * time.Millisecond
+
+func (a *App) backgroundCloseHasRestorePath() bool {
+	if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
+		return backgroundCloseHasRestorePathFor(goruntime.GOOS, false, false)
+	}
+	if !a.startTray() {
+		return false
+	}
+	return backgroundCloseHasRestorePathFor(goruntime.GOOS, true, a.waitForTrayReady(backgroundCloseTrayReadyTimeout))
+}
+
+func (a *App) waitForTrayReady(timeout time.Duration) bool {
+	if a.isTrayReady() {
+		return true
+	}
+	ready := a.trayReadySignal()
+	if ready == nil {
+		return false
+	}
+	if timeout <= 0 {
+		select {
+		case <-ready:
+			return a.isTrayReady()
+		default:
+			return false
+		}
+	}
+	timer := time.NewTimer(timeout)
+	defer timer.Stop()
+	select {
+	case <-ready:
+		return a.isTrayReady()
+	case <-timer.C:
+		return a.isTrayReady()
+	}
+}
+
+func (a *App) isTrayReady() bool {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	return a.trayReady
+}
+
+func (a *App) trayReadySignal() <-chan struct{} {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if a.tray == nil {
+		return nil
+	}
+	return a.tray.ready
+}
+
+// markTabsRestored closes the tabsRestored gate exactly once. Safe when the
+// channel was never created (tests that drive App without startup).
+func (a *App) markTabsRestored() {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	if a.tabsRestored == nil {
+		return
+	}
+	select {
+	case <-a.tabsRestored:
+	default:
+		close(a.tabsRestored)
+	}
+}
+
+// tabsRestoredSignal returns a channel closed once tab restore has completed.
+// When startup never armed the gate (tests), it reports already-restored.
+func (a *App) tabsRestoredSignal() <-chan struct{} {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if a.tabsRestored == nil {
+		closed := make(chan struct{})
+		close(closed)
+		return closed
+	}
+	return a.tabsRestored
+}
+
+func (a *App) showMainWindow() {
+	if a.ctx != nil {
+		showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
+		a.kickDeferredRebuildRetry()
+	}
+}
+
+func (a *App) secondInstanceLaunch() {
+	a.showMainWindow()
+}
+
+func (a *App) quitApp() {
+	if a.ctx == nil {
+		return
+	}
+	a.forceQuit.Store(true)
+	runtime.Quit(a.ctx)
+}
+
+func hideForBackground(ctx context.Context) {
+	if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
+		runtime.Hide(ctx)
+		return
+	}
+	runtime.WindowHide(ctx)
+}
+
+func showFromBackground(ctx context.Context, wasMaximised bool) {
+	if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
+		runtime.Show(ctx)
+	}
+	plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)
+	if plan.maximiseBeforeShow {
+		runtime.WindowMaximise(ctx)
+	}
+	runtime.WindowShow(ctx)
+	if plan.unminimiseAfterShow {
+		runtime.WindowUnminimise(ctx)
+	}
+}
+
+func backgroundCloseUsesApplicationHide(goos string) bool {
+	return goos == "darwin"
+}
+
+func backgroundCloseHasRestorePathFor(goos string, trayStarted, trayReady bool) bool {
+	return backgroundCloseUsesApplicationHide(goos) || (trayStarted && trayReady)
+}
+
+type backgroundRestorePlan struct {
+	maximiseBeforeShow  bool
+	unminimiseAfterShow bool
+}
+
+func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
+	if backgroundRestoreShouldMaximise(goos, wasMaximised) {
+		return backgroundRestorePlan{maximiseBeforeShow: true}
+	}
+	return backgroundRestorePlan{unminimiseAfterShow: true}
+}
+
+func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
+	return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
+}
+
+// restoreOrBuildTabs restores the tabs from the last session, or creates a
+// default Global tab on first launch.
+func (a *App) restoreOrBuildTabs() {
+	defer a.recoverToPending("restoreOrBuildTabs")
+	// Unblock startup work gated on the restore (recovery GC) no matter how
+	// this returns — including the recover path above.
+	defer a.markTabsRestored()
+	// Reap any orphaned codegraph processes from a previous crash or older
+	// version that leaked them, so they don't accumulate across restarts.
+	a.reapOrphanCodeGraph()
+	ctx := a.ctx
+	ensureWorkspace()
+
+	// Run legacy config migration before the first config load so the
+	// freshly written config (including the user's default_model) is
+	// picked up by Load instead of falling back to built-in defaults.
+	_, _ = config.MigrateLegacyIfNeeded()
+	f := loadTabsFile()
+	_, _ = recoverLegacyProjectSidebarRoots(f)
+	_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
+	_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
+
+	// Load i18n from the first available config.
+	// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),
+	// so the user's language choice in desktop settings takes effect.
+	startupCfg, cfgErr := config.Load()
+	if cfgErr == nil {
+		cfg := startupCfg
+		lang := cfg.DesktopLanguage()
+		if lang == "" {
+			lang = cfg.Language
+		}
+		i18n.DetectLanguage(lang)
+	}
+	if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
+		f = singleSurfaceTabsFile(f)
+	}
+
+	if len(f.Tabs) > 0 {
+		toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
+		for _, entry := range f.Tabs {
+			a.mu.Lock()
+			id := a.restoredTabIDLocked(entry.ID)
+			a.mu.Unlock()
+
+			var tab *WorkspaceTab
+			if entry.Scope == "project" {
+				tab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)
+			} else {
+				tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id)
+			}
+			tab.model = entry.Model
+			tab.effort = cloneStringPtr(entry.Effort)
+			tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
+			tab.mode = persistedTabMode(entry.Mode)
+			// Validate the persisted goal against the session's goal-state
+			// sidecar: a typed /new or /clear rotates the session through the
+			// controller without passing App.NewSession/ClearSession, so
+			// entry.Goal can be stale. Session rotation writes a stopped
+			// goal-state onto the fresh path; reading it here stops a restart
+			// from re-seeding the cleared goal into the rotated session. A
+			// session without a sidecar keeps the persisted goal (legacy).
+			tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
+			tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
+			if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
+				tab.toolApprovalMode = control.ToolApprovalYolo
+			}
+			tab.SessionPath = strings.TrimSpace(entry.SessionPath)
+			tab.ReadOnly = entry.ReadOnly
+			tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
+			a.mu.Lock()
+			a.tabs[tab.ID] = tab
+			a.tabOrder = append(a.tabOrder, tab.ID)
+			a.mu.Unlock()
+			toBuild = append(toBuild, tab)
+		}
+		a.mu.Lock()
+		if _, ok := a.tabs[f.ActiveTab]; ok {
+			a.activeTabID = f.ActiveTab
+		} else {
+			ordered := a.orderedTabIDsLocked()
+			if len(ordered) > 0 {
+				a.activeTabID = ordered[0]
+			}
+		}
+		a.saveTabsLocked()
+		a.mu.Unlock()
+		for _, tab := range toBuild {
+			a.startTabControllerBuild(tab)
+		}
+		return
+	}
+
+	// First launch: create a default Global tab.
+	tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "")
+	tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
+	tab.TopicTitle = "Global"
+	a.mu.Lock()
+	a.tabs[tab.ID] = tab
+	a.tabOrder = append(a.tabOrder, tab.ID)
+	a.activeTabID = tab.ID
+	a.mu.Unlock()
+	a.startTabControllerBuild(tab)
+}
+
+func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {
+	return a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())
+}
+
+func desktopNewSessionDefaults() (string, string) {
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	return strings.TrimSpace(cfg.DefaultModel), normalizeToolApprovalMode(cfg.DesktopDefaultToolApprovalMode())
+}
+
+func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {
+	model, toolApprovalMode := desktopNewSessionDefaults()
+	return &WorkspaceTab{
+		ID:               id,
+		Scope:            scope,
+		WorkspaceRoot:    workspaceRoot,
+		TopicID:          topicID,
+		TopicTitle:       topicTitleForTab(scope, workspaceRoot, topicID),
+		model:            model,
+		tokenMode:        boot.TokenModeFull,
+		mode:             tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
+		toolApprovalMode: toolApprovalMode,
+		disabledMCP:      map[string]ServerView{},
+	}
+}
+
+func (a *App) snapshotAllTabs() {
+	a.mu.RLock()
+	tabs := a.runtimeTabsLocked()
+	a.mu.RUnlock()
+	for _, t := range tabs {
+		if err := a.snapshotTab(t); err != nil {
+			slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
+		}
+	}
+}
+
+// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
+func (a *App) shutdown(context.Context) {
+	a.stopDeferredRebuildRetry()
+	a.stopMainThreadWatchdog()
+	if a.heartbeat != nil {
+		a.heartbeat.Stop()
+	}
+	a.stopBotRuntime()
+	a.stopTray()
+	// Save window geometry synchronously from Go so it's persisted even if the
+	// frontend's beforeunload promise hasn't resolved yet.
+	a.saveWindowStateSync()
+	// Close every shared plugin host on exit, even if a tab cleanup panics.
+	defer a.closeAllSharedHosts()
+
+	a.mu.RLock()
+	tabs := a.runtimeTabsLocked()
+	type shutdownItem struct {
+		tab  *WorkspaceTab
+		ctrl control.SessionAPI
+	}
+	items := make([]shutdownItem, 0, len(tabs))
+	for _, t := range tabs {
+		if t.Ctrl != nil {
+			items = append(items, shutdownItem{tab: t, ctrl: t.Ctrl})
+		}
+	}
+	a.mu.RUnlock()
+	for _, it := range items {
+		if err := a.snapshotTab(it.tab); err != nil {
+			slog.Warn("desktop: shutdown snapshot failed", "tab", it.tab.ID, "err", err)
+		}
+		it.ctrl.Close()
+		it.tab.releaseSessionLease()
+	}
+}
+
+// domReady is called (via OnDomReady) after the webview finishes loading its DOM
+// but before the window is shown (StartHidden). It restores the saved window
+// position and size, then calls WindowShow so the user never sees the default
+// size/position flash.
+func (a *App) domReady(_ context.Context) {
+	state, ok := loadWindowState()
+	if ok {
+		// Validate saved position against current screens. Wails v2 doesn't
+		// expose per-screen origin (x,y offsets) so we can only do a basic
+		// sanity check: ensure the window origin falls within a generous
+		// estimate of the screen area. If the user unplugged an external
+		// display, negative or out-of-bounds coordinates are caught here.
+		valid := state.X >= 0 && state.Y >= 0
+		if valid {
+			screens, err := runtime.ScreenGetAll(a.ctx)
+			if err == nil && len(screens) > 0 {
+				maxW, maxH := 0, 0
+				for _, sc := range screens {
+					if sc.Size.Width > maxW {
+						maxW = sc.Size.Width
+					}
+					if sc.Size.Height > maxH {
+						maxH = sc.Size.Height
+					}
+				}
+				if state.X > maxW*2 || state.Y > maxH*2 {
+					valid = false
+				}
+			}
+		}
+		if valid {
+			runtime.WindowSetPosition(a.ctx, state.X, state.Y)
+		} else {
+			runtime.WindowCenter(a.ctx)
+		}
+	} else {
+		runtime.WindowCenter(a.ctx)
+	}
+
+	if ok && state.Maximised {
+		runtime.WindowMaximise(a.ctx)
+	}
+
+	runtime.WindowShow(a.ctx)
+}
+
+// --- bound command surface (frontend → controller) ---
+// Each method guards on a nil controller so a pre-startup or failed-build call is
+// a no-op, never a panic.
+
+// Submit runs raw user input as a turn; slash commands and @-references are
+// resolved by the controller. Output arrives asynchronously on eventChannel.
+func (a *App) Submit(input string) error {
+	return a.SubmitToTab("", input)
+}
+
+func (a *App) SubmitToTab(tabID, input string) error {
+	return a.submitToTab(tabID, input, false)
+}
+
+// beginTabTurn locks the tab's foreground-turn admission gate and reserves the
+// event sink until TurnDone has completed all of its fan-out. The caller must
+// call finishTabTurnStart exactly once after invoking the controller.
+func (a *App) beginTabTurn(tabID string, reclaim bool) (*WorkspaceTab, control.SessionAPI, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return nil, nil, readOnlyChannelErr()
+	}
+	if tab == nil || ctrl == nil {
+		return nil, nil, a.workspaceNotReadyErr(tab)
+	}
+	tab.turnStartMu.Lock()
+	if a.tabIsReadOnly(tab) {
+		tab.turnStartMu.Unlock()
+		return nil, nil, readOnlyChannelErr()
+	}
+	if reclaim && a.botBridge != nil {
+		a.botBridge.reclaimFromDesktop(tab.ID)
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		tab.turnStartMu.Unlock()
+		return nil, nil, a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		tab.turnStartMu.Unlock()
+		return nil, nil, err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		tab.turnStartMu.Unlock()
+		return nil, nil, a.workspaceNotReadyErr(tab)
+	}
+	if ctrl.RuntimeStatus().Running || (tab.sink != nil && !tab.sink.tryBeginTurn()) {
+		tab.turnStartMu.Unlock()
+		return nil, nil, control.ErrTurnRunning
+	}
+	return tab, ctrl, nil
+}
+
+func (a *App) finishTabTurnStart(tab *WorkspaceTab, ctrl control.SessionAPI) bool {
+	started := ctrl != nil && ctrl.RuntimeStatus().Running
+	if !started && tab != nil && tab.sink != nil {
+		tab.sink.cancelTurnStart()
+	}
+	if tab != nil {
+		tab.turnStartMu.Unlock()
+	}
+	return started
+}
+
+// submitToTab is the shared submit body. fromBridge marks submissions driven
+// by the IM takeover bridge; local (frontend) submissions on a taken-over tab
+// reclaim remote control first — typing locally is the grab-back gesture.
+func (a *App) submitToTab(tabID, input string, fromBridge bool) error {
+	trimmed := strings.TrimSpace(input)
+	if trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") {
+		tab, _ := a.tabAndCtrlByID(tabID)
+		if a.tabIsReadOnly(tab) {
+			return readOnlyChannelErr()
+		}
+		if tab == nil {
+			return a.workspaceNotReadyErr(tab)
+		}
+		tab.turnStartMu.Lock()
+		defer tab.turnStartMu.Unlock()
+		if !fromBridge && a.botBridge != nil {
+			a.botBridge.reclaimFromDesktop(tab.ID)
+		}
+		a.runEffortCommandForTab(tabID, trimmed)
+		return nil
+	}
+	tab, ctrl, err := a.beginTabTurn(tabID, !fromBridge)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitDisplay(input, input)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool {
+	tab, ctrl, err := a.beginTabTurn(tabID, false)
+	if err != nil {
+		return false
+	}
+	var generation uint64
+	if forwarder != nil {
+		generation = tab.sink.SetBotSink(forwarder)
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitUserTurn(input, input)
+	started := a.finishTabTurnStart(tab, ctrl)
+	if !started && forwarder != nil {
+		tab.sink.clearBotSink(generation)
+	}
+	return started
+}
+
+// RunShell executes a shell command directly (bypassing the model) and streams
+// output as events on eventChannel.
+func (a *App) RunShell(command string) error {
+	return a.RunShellForTab("", command)
+}
+
+func (a *App) RunShellForTab(tabID, command string) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.RunShell(command)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+// SubmitDisplay runs input as a turn while recording a shorter UI-only display
+// string for the saved desktop transcript. The model still receives input.
+func (a *App) SubmitDisplay(display, input string) error {
+	return a.SubmitDisplayToTab("", display, input)
+}
+
+func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitDisplay(display, input)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+// InvocationRequest is the Wails-bound form of a composer invocation entity.
+type InvocationRequest struct {
+	Name   string `json:"name"`
+	Kind   string `json:"kind"`
+	Offset int    `json:"offset"`
+}
+
+func (a *App) SubmitInvocationsToTab(tabID, display, input string, invocations []InvocationRequest) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	requests := make([]control.InvocationRequest, 0, len(invocations))
+	for _, invocation := range invocations {
+		requests = append(requests, control.InvocationRequest{
+			Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset,
+		})
+	}
+	ctrl.SubmitInvocationDisplay(display, input, requests)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitEditedDisplay(display, input, original)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+func (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {
+	if ctrl == nil {
+		return
+	}
+	ctrl.SetDisplayRecorder(func(content, display string) {
+		dir := ctrl.SessionDir()
+		if dir == "" {
+			dir = config.SessionDir()
+		}
+		_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)
+	})
+}
+
+// Cancel aborts the in-flight turn.
+func (a *App) Cancel() {
+	a.CancelTab("")
+}
+
+func (a *App) CancelTab(tabID string) {
+	if ctrl := a.ctrlByTabID(tabID); ctrl != nil {
+		ctrl.Cancel()
+	}
+}
+
+// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.
+func (a *App) Steer(text string) error {
+	return a.SteerForTab("", text)
+}
+
+// SteerForTab sends mid-turn guidance to a specific tab's agent.
+func (a *App) SteerForTab(tabID, text string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	ctrl.Steer(text)
+	return nil
+}
+
+func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.RUnlock()
+		return nil, nil
+	}
+	ctrl := tab.Ctrl
+	retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
+	a.mu.RUnlock()
+	if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
+		a.mu.RLock()
+		defer a.mu.RUnlock()
+		if a.tabs[tab.ID] != tab {
+			return nil, nil
+		}
+		return tab, tab.Ctrl
+	}
+	return tab, ctrl
+}
+
+// activeTabAndCtrl snapshots the active tab and its controller in one locked
+// read, so callers never do a check-then-use on tab.Ctrl after the lock is
+// released (a rebuild can swap the controller in between).
+func (a *App) activeTabAndCtrl() (*WorkspaceTab, control.SessionAPI) {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	tab := a.activeTabLocked()
+	if tab == nil {
+		return nil, nil
+	}
+	return tab, tab.Ctrl
+}
+
+func (a *App) controllerForTab(tab *WorkspaceTab) control.SessionAPI {
+	if tab == nil {
+		return nil
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if tab.ID != "" && a.tabs[tab.ID] != tab {
+		return nil
+	}
+	return tab.Ctrl
+}
+
+// currentSessionPathFor is the locked form of tab.currentSessionPath: it
+// snapshots Ctrl/SessionPath under a.mu, then queries the controller off-lock.
+// Use it on paths that do not otherwise hold a.mu.
+func (a *App) currentSessionPathFor(tab *WorkspaceTab) string {
+	if tab == nil {
+		return ""
+	}
+	a.mu.RLock()
+	ctrl := tab.Ctrl
+	fallback := strings.TrimSpace(tab.SessionPath)
+	a.mu.RUnlock()
+	if ctrl != nil {
+		if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
+			return path
+		}
+	}
+	return fallback
+}
+
+// sessionDirForSnapshot mirrors tabSessionDir for callers that hold a
+// tabRuntimeSnapshot instead of reading the live tab.
+func sessionDirForSnapshot(s tabRuntimeSnapshot) string {
+	if s.workspaceRoot != "" {
+		return desktopSessionDir(s.workspaceRoot)
+	}
+	if s.ctrl != nil {
+		if dir := s.ctrl.SessionDir(); dir != "" {
+			return dir
+		}
+	}
+	return desktopSessionDir("")
+}
+
+func readOnlyChannelErr() error {
+	return fmt.Errorf("channel session is read-only")
+}
+
+func (a *App) snapshotTab(tab *WorkspaceTab) error {
+	if tab == nil {
+		return nil
+	}
+	a.mu.RLock()
+	readOnly := tab.ReadOnly
+	ctrl := tab.Ctrl
+	a.mu.RUnlock()
+	if readOnly || ctrl == nil {
+		return nil
+	}
+	return ctrl.Snapshot()
+}
+
+func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
+	if err := a.snapshotTab(tab); err != nil {
+		a.reportTabSnapshotError(tab, action, err)
+		if strings.TrimSpace(action) == "" {
+			return fmt.Errorf("save current session: %w", err)
+		}
+		return fmt.Errorf("save current session before %s: %w", action, err)
+	}
+	return nil
+}
+
+func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
+	if err == nil {
+		return
+	}
+	tabID := ""
+	if tab != nil {
+		tabID = tab.ID
+	}
+	slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
+	if tab == nil || tab.sink == nil {
+		return
+	}
+	// Autosave fires once per turn; on a persistently failing disk that would
+	// stream a chat warning after every turn. Rate-limit the user-facing
+	// notice per tab (the slog line above always records every failure). Saves
+	// triggered by an explicit action are one-shot and always surface.
+	if action == "autosave" {
+		tab.saveMu.Lock()
+		now := time.Now()
+		if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
+			tab.saveMu.Unlock()
+			return
+		}
+		tab.lastAutosaveWarnAt = now
+		tab.saveMu.Unlock()
+	}
+	prefix := "Session autosave failed"
+	if strings.TrimSpace(action) != "" && action != "autosave" {
+		prefix = "Session save failed before " + action
+	}
+	tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
+}
+
+func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
+	if tab == nil {
+		return ""
+	}
+	path, _ := a.reconcileTabWithPinnedSessionMeta(tab)
+	if ctrl := a.controllerForTab(tab); path == "" && ctrl != nil {
+		path = ctrl.SessionPath()
+	}
+	return path
+}
+
+func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
+	if tab == nil {
+		return nil
+	}
+	tab.reconcileMu.Lock()
+	defer tab.reconcileMu.Unlock()
+
+	a.mu.RLock()
+	current := a.tabs[tab.ID]
+	ctrl := tab.Ctrl
+	readOnly := tab.ReadOnly
+	a.mu.RUnlock()
+	if current != tab || ctrl == nil || readOnly {
+		return nil
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return nil
+	}
+	path, hasBinding := a.reconcileTabWithPinnedSessionMeta(tab)
+	desiredRoot := strings.TrimSpace(tab.WorkspaceRoot)
+	ctrlRoot, rootOK := safeControllerWorkspaceRoot(ctrl)
+	ctrlDir, dirOK := safeControllerSessionDir(ctrl)
+	if !rootOK || !dirOK {
+		return nil
+	}
+	if !hasBinding {
+		if desiredRoot == "" || strings.TrimSpace(ctrlRoot) == "" || sameDesktopPath(ctrlRoot, desiredRoot) {
+			return nil
+		}
+	}
+	desiredDir := tabSessionDir(tab)
+	rootMatches := desiredRoot == "" || sameDesktopPath(ctrlRoot, desiredRoot)
+	dirMatches := desiredDir == "" || sameDesktopPath(ctrlDir, desiredDir)
+	if !dirMatches && path != "" {
+		if validPath, _, err := validateSessionPath(ctrlDir, path); err == nil && sessionRuntimeKey(validPath) == sessionRuntimeKey(path) {
+			dirMatches = true
+		}
+	}
+	if strings.TrimSpace(ctrlRoot) == "" && dirMatches {
+		rootMatches = true
+	}
+	if tab.Scope == "global" {
+		if strings.TrimSpace(ctrlRoot) == "" {
+			rootMatches = true
+		}
+		if sameDesktopPath(ctrlDir, config.SessionDir()) || sameDesktopPath(ctrlDir, desktopSessionDir(globalWorkspaceRoot())) {
+			dirMatches = true
+		}
+	}
+	sessionMatches := path == "" || sessionRuntimeKey(ctrl.SessionPath()) == sessionRuntimeKey(path)
+	if rootMatches && dirMatches && sessionMatches {
+		return nil
+	}
+	if err := ctrl.Snapshot(); err != nil {
+		return err
+	}
+	ctrl.Close()
+
+	a.mu.Lock()
+	var hostKey string
+	if current := a.tabs[tab.ID]; current == tab {
+		tab.Ctrl = nil
+		tab.Ready = false
+		clearTabStartupError(tab)
+		tab.ActivityStatus = ""
+		if tab.sink == nil {
+			tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+		}
+		hostKey = takeTabSharedHostKey(tab)
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+	if hostKey != "" {
+		a.releaseSharedHost(hostKey)
+	}
+
+	a.buildTabController(tab)
+	if tab.Ctrl == nil {
+		if tab.StartupErr != "" {
+			return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr)
+		}
+		return fmt.Errorf("workspace failed to restart with corrected root")
+	}
+	return nil
+}
+
+func safeControllerWorkspaceRoot(ctrl control.SessionAPI) (root string, ok bool) {
+	if ctrl == nil {
+		return "", false
+	}
+	defer func() {
+		if recover() != nil {
+			root = ""
+			ok = false
+		}
+	}()
+	return ctrl.WorkspaceRoot(), true
+}
+
+func safeControllerSessionDir(ctrl control.SessionAPI) (dir string, ok bool) {
+	if ctrl == nil {
+		return "", false
+	}
+	defer func() {
+		if recover() != nil {
+			dir = ""
+			ok = false
+		}
+	}()
+	return ctrl.SessionDir(), true
+}
+
+// Approve answers a pending approval_request by ID: allow runs the call, session
+// also remembers the grant for the rest of the session.
+func (a *App) Approve(id string, allow, session, persist bool) {
+	ctrl := a.ctrlByTabID("")
+	if ctrl != nil {
+		ctrl.Approve(id, allow, session, persist)
+	}
+}
+
+// ApproveTab is like Approve but scoped to a specific tab.
+func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl != nil {
+		ctrl.Approve(id, allow, session, persist)
+	}
+}
+
+// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask
+// prompt that is currently blocking its run loop. The frontend calls this once
+// its event subscription is live (on load/reconnect) so a session that was
+// already awaiting confirmation rebuilds its modal instead of showing a
+// "waiting" status with no way to answer — and no way to stop.
+func (a *App) ReplayPendingPrompts() {
+	a.mu.RLock()
+	tabs := a.runtimeTabsLocked()
+	ctrls := make([]control.SessionAPI, 0, len(tabs))
+	for _, t := range tabs {
+		if t.Ctrl != nil {
+			ctrls = append(ctrls, t.Ctrl)
+		}
+	}
+	a.mu.RUnlock()
+	for _, ctrl := range ctrls {
+		ctrl.ReplayPendingPrompts()
+	}
+}
+
+// SetPlanMode toggles the read-only plan axis while preserving the current
+// tool-auto-approval axis.
+func (a *App) SetPlanMode(on bool) {
+	a.setPlanModeForTab("", on)
+}
+
+func (a *App) setPlanModeForTab(tabID string, on bool) {
+	current := a.currentModeForTab(tabID)
+	a.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))
+}
+
+// SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" |
+// anything else =
+// normal) in one call, so a turn submitted right after the switch can't race a
+// half-applied plan/tool-auto-approval pair.
+func (a *App) SetMode(mode string) {
+	a.SetModeForTab("", mode)
+}
+
+func (a *App) SetModeForTab(tabID, mode string) {
+	normalized := normalizeTabMode(mode)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	tab.mode = normalized
+	tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)
+	if tabModeHasAutoApproveTools(normalized) {
+		tab.toolApprovalMode = control.ToolApprovalYolo
+	} else if tab.toolApprovalMode == control.ToolApprovalYolo {
+		tab.toolApprovalMode = control.ToolApprovalAsk
+	}
+	ctrl := tab.Ctrl
+	approvalMode := tab.toolApprovalMode
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	applyTabModeToController(ctrl, normalized)
+	applyTabToolApprovalModeToController(ctrl, approvalMode)
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func applyTabModeToController(ctrl control.SessionAPI, mode string) {
+	if ctrl == nil {
+		return
+	}
+	switch normalizeTabMode(mode) {
+	case "plan":
+		ctrl.SetMode(true, false)
+	case "yolo":
+		ctrl.SetMode(false, true)
+	case "plan-yolo":
+		ctrl.SetMode(true, true)
+	default:
+		ctrl.SetMode(false, false)
+	}
+}
+
+func applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {
+	if ctrl == nil {
+		return
+	}
+	ctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))
+}
+
+func (a *App) currentModeForTab(tabID string) string {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	mode := "normal"
+	if tab != nil {
+		mode = currentTabMode(tab)
+	}
+	a.mu.RUnlock()
+	return mode
+}
+
+func normalizeCollaborationMode(mode string) string {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "plan":
+		return "plan"
+	case "goal":
+		return "goal"
+	default:
+		return "normal"
+	}
+}
+
+func (a *App) SetCollaborationMode(mode string) {
+	a.SetCollaborationModeForTab("", mode)
+}
+
+func (a *App) SetCollaborationModeForTab(tabID, mode string) {
+	mode = normalizeCollaborationMode(mode)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	approvalMode := currentTabToolApprovalMode(tab)
+	switch mode {
+	case "plan":
+		tab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)
+		tab.goal = ""
+	case "goal":
+		tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
+	default:
+		tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
+		tab.goal = ""
+	}
+	ctrl := tab.Ctrl
+	goal := tab.goal
+	plan := tabModeHasPlan(tab.mode)
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	if ctrl != nil {
+		ctrl.SetPlanMode(plan)
+		ctrl.SetGoal(goal)
+	}
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+// QuestionAnswer is the frontend's reply to one question in an ask_request.
+type QuestionAnswer struct {
+	QuestionID string   `json:"questionId"`
+	Selected   []string `json:"selected"`
+}
+
+// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the
+// user's selections per question.
+func (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {
+	a.AnswerQuestionForTab("", id, answers)
+}
+
+func (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return
+	}
+	out := make([]event.AskAnswer, len(answers))
+	for i, an := range answers {
+		out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
+	}
+	ctrl.AnswerQuestion(id, out)
+}
+
+// Compact runs a plain compaction pass (the "compact now" button). Focus-guided
+// compaction goes through Submit("/compact ") instead.
+func (a *App) Compact() error {
+	return a.CompactForTab("")
+}
+
+// CompactForTab compacts the requested tab without depending on which tab is
+// focused when the asynchronous frontend call reaches the backend.
+func (a *App) CompactForTab(tabID string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.Compact(a.ctx, "")
+}
+
+// workspaceNotReadyErr names why a session action arrived before the tab's
+// controller existed: still starting, or failed to start. Silently returning
+// nil here swallowed the click with no feedback (#3938).
+//
+// This is the bound-method form: StartupErr is written under a.mu by the
+// build goroutine while Submit-family calls race it, so read it under the
+// lock. Callers must not hold a.mu.
+func (a *App) workspaceNotReadyErr(tab *WorkspaceTab) error {
+	startupErr := ""
+	if tab != nil {
+		a.mu.RLock()
+		startupErr = tab.StartupErr
+		a.mu.RUnlock()
+	}
+	if strings.TrimSpace(startupErr) != "" {
+		return fmt.Errorf("workspace failed to start: %s", startupErr)
+	}
+	return fmt.Errorf("workspace is still starting")
+}
+
+// tabIsReadOnly reads tab.ReadOnly under a.mu; setTabReadOnly can flip it
+// concurrently with Submit-family bound calls. Callers must not hold a.mu.
+func (a *App) tabIsReadOnly(tab *WorkspaceTab) bool {
+	if tab == nil {
+		return false
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	return tab.ReadOnly
+}
+
+// NewSession snapshots the current conversation and rotates to a fresh one.
+func (a *App) NewSession() error {
+	return a.NewSessionForTab("")
+}
+
+// NewSessionForTab snapshots and rotates the requested tab regardless of which
+// tab becomes active while the Wails call is in flight.
+func (a *App) NewSessionForTab(tabID string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	// Tab is already blank — just persist and skip the new-session dance.
+	if !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {
+		a.persistTabSessionPath(tab, ctrl.SessionPath())
+		return nil
+	}
+
+	if err := ctrl.NewSession(); err != nil {
+		return err
+	}
+	// The rotated session starts with zero spend: without this reset the tab
+	// telemetry keeps the previous session's totals and the status bar 会话费用
+	// silently turns into an all-sessions running total (#5850).
+	tab.resetTelemetry(ctrl.SessionPath())
+	// Mirror the controller: NewSession cleared the active goal, and the tab's
+	// persisted copy must follow — otherwise the next rebuild/restart would
+	// re-seed the old goal into the fresh session via SetGoal(tab.goal).
+	a.clearTabGoal(tab)
+	a.assignFreshSessionTopic(tab)
+	a.persistTabSessionPath(tab, ctrl.SessionPath())
+	a.invalidatePromptHistoryCache()
+	a.emitProjectTreeChanged()
+	return nil
+}
+
+func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
+	if tab == nil {
+		return
+	}
+	topicID := newTopicID()
+	a.mu.Lock()
+	scope := tab.Scope
+	workspaceRoot := tab.WorkspaceRoot
+	tab.TopicID = topicID
+	tab.TopicTitle = defaultTopicTitle
+	if current := a.tabs[tab.ID]; current == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+	if strings.TrimSpace(scope) == "global" {
+		workspaceRoot = ""
+	} else {
+		workspaceRoot = normalizeProjectRoot(workspaceRoot)
+	}
+	// NewSession already rotated the runtime to a fresh session. If the sidebar
+	// topic index repair fails here, keep the session usable and let persisted
+	// session metadata repair the topic index later instead of surfacing a false
+	// "new session failed" error to the frontend.
+	_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
+	_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
+}
+
+func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
+	if tab == nil {
+		return
+	}
+	topicID := newTopicID()
+	a.mu.Lock()
+	if strings.TrimSpace(tab.TopicID) != "" {
+		a.mu.Unlock()
+		return
+	}
+	scope := tab.Scope
+	workspaceRoot := tab.WorkspaceRoot
+	tab.TopicID = topicID
+	tab.TopicTitle = defaultTopicTitle
+	if current := a.tabs[tab.ID]; current == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+	if strings.TrimSpace(scope) == "global" {
+		scope = "global"
+		workspaceRoot = ""
+	} else {
+		scope = "project"
+		workspaceRoot = normalizeProjectRoot(workspaceRoot)
+	}
+
+	_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
+	_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
+	a.persistTabSessionPath(tab, a.currentSessionPathFor(tab))
+	a.emitProjectTreeChanged()
+}
+
+func messagesHaveConversationContent(messages []provider.Message) bool {
+	for _, msg := range messages {
+		if msg.Role != provider.RoleSystem {
+			return true
+		}
+	}
+	return false
+}
+
+// ClearSession discards the current conversation and rotates to a fresh unsaved one.
+func (a *App) ClearSession() error {
+	return a.ClearSessionForTab("")
+}
+
+// ClearSessionForTab clears the requested tab regardless of later focus changes.
+func (a *App) ClearSessionForTab(tabID string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return a.clearActiveSessionRuntime(tab, ctrl)
+	}
+	if err := ctrl.ClearSession(); err != nil {
+		return err
+	}
+	if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
+		// Wails bridge return: a raw lease error would carry the session path
+		// and holder id across to the frontend.
+		return userFacingSessionLeaseError("", err)
+	}
+	tab.resetTelemetry(ctrl.SessionPath())
+	// Mirror the controller: ClearSession cleared the active goal.
+	a.clearTabGoal(tab)
+	a.persistTabSessionPath(tab, ctrl.SessionPath())
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
+// cannot re-seed a goal the controller has already cleared on session rotation.
+func (a *App) clearTabGoal(tab *WorkspaceTab) {
+	if tab == nil {
+		return
+	}
+	a.mu.Lock()
+	tab.goal = ""
+	if current := a.tabs[tab.ID]; current == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
+	if tab == nil || oldCtrl == nil {
+		return fmt.Errorf("workspace is still starting")
+	}
+	// This is a build+swap of the tab's controller; serialize with the other
+	// rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings
+	// rebuild cannot interleave a second swap. Lock order:
+	// runtimeRebuildMu → sessionRemovalMu (no path acquires them in reverse).
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	// This path destroys the old session's files (removeDesktopSessionArtifacts);
+	// serialize with DeleteSession/TrashTopic/workspace removal so they never
+	// trash or restore the same files mid-clear.
+	a.sessionRemovalMu.Lock()
+	defer a.sessionRemovalMu.Unlock()
+
+	a.reconciledSessionPathForTab(tab)
+	oldPath := oldCtrl.SessionPath()
+	// Snapshot the tab profile under a.mu: bound methods write these fields
+	// under the lock while this rebuild runs off-lock.
+	snap := a.tabRuntimeSnapshot(tab)
+	oldSink := snap.sink
+	if oldSink != nil {
+		// Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
+		// derives — a raw path here would hash to a different detached id on
+		// Windows where keys are case-folded.
+		oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
+		oldSink.clearContext()
+	}
+	if oldCtrl.RuntimeStatus().Cancellable {
+		oldCtrl.Cancel()
+		if err := waitControllerStopped(oldCtrl); err != nil {
+			return err
+		}
+	}
+	destroy := oldCtrl.BeginDestroySession(oldPath)
+	destroys := []control.SessionDestroyHandle{destroy}
+	teardownTimedOut := waitDestroyHandles(destroys)
+	if teardownTimedOut {
+		if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
+			return err
+		}
+	}
+
+	newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    snap.model,
+		RequireKey:               false,
+		Sink:                     newSink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           cloneStringPtr(snap.effort),
+		TokenMode:                snap.currentTokenMode(),
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		if teardownTimedOut {
+			// The old session was already marked cleanup-pending, so finish the
+			// destroy cleanup instead of re-exposing a runtime in teardown.
+			go delayedDesktopSessionCleanup(oldPath, destroys)
+		} else {
+			finishDestroyHandles(destroys)
+		}
+		if oldSink != nil {
+			oldSink.setBinding(tab.ID, nil)
+			oldSink.setContext(a.ctx)
+		}
+		return err
+	}
+	if teardownTimedOut {
+		go delayedDesktopSessionCleanup(oldPath, destroys)
+	} else {
+		if err := removeDesktopSessionArtifacts(oldPath); err != nil {
+			finishDestroyHandles(destroys)
+			newCtrl.Close()
+			return err
+		}
+		finishDestroyHandles(destroys)
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	// Clearing the session clears the active goal too (same contract as
+	// Controller.ClearSession): the snapshot's goal belongs to the destroyed
+	// conversation and must not seed the replacement.
+	path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
+	if err := tab.ensureSessionLease(path); err != nil {
+		newCtrl.Close()
+		// Surfaces through ClearSession's Wails return; keep the holder's
+		// path/pid/writer id out of it.
+		return userFacingSessionLeaseError("", err)
+	}
+	newCtrl.SetSessionPath(path)
+
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		a.mu.Unlock()
+		// The old session is already destroyed either way; release what this
+		// clear acquired for the replaced tab (fresh controller and its
+		// lease) so neither leaks, and still finish the old runtime teardown.
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		oldCtrl.CloseAfterDestroy()
+		a.emitProjectTreeChanged()
+		return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.sink = newSink
+	tab.SessionPath = path
+	tab.Label = newCtrl.Label()
+	tab.Ready = true
+	clearTabStartupError(tab)
+	tab.goal = ""
+	// Supersede any in-flight startup build: the session it was resuming
+	// was just destroyed, and finishing later would pass the generation
+	// check and overwrite this controller.
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	// Same contract as ClearSession's non-running path: the replacement
+	// session starts with zero spend.
+	tab.resetTelemetry(path)
+	oldCtrl.CloseAfterDestroy()
+	a.emitProjectTreeChanged()
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func removeDesktopSessionArtifacts(path string) error {
+	if strings.TrimSpace(path) == "" {
+		return nil
+	}
+	guard, err := acquireSessionRemovalGuard(path)
+	if err != nil {
+		return err
+	}
+	defer guard.Release()
+	if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
+		return err
+	}
+	defer invalidateTopicSessionIndexForPath(path)
+	for _, p := range sessionOwnedArtifactPaths(path) {
+		if strings.TrimSpace(p) == "" {
+			continue
+		}
+		if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {
+			return err
+		}
+	}
+	if err := guard.RemoveSidecarsAndRelease(); err != nil {
+		return err
+	}
+	if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
+		return err
+	}
+	if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
+		return err
+	}
+	return agent.ClearCleanupPending(path)
+}
+
+// CheckpointMeta summarises one rewind point (a user turn) for the desktop.
+type CheckpointMeta struct {
+	Turn            int      `json:"turn"`
+	Prompt          string   `json:"prompt"`
+	Files           []string `json:"files"`     // stable preview of cumulative files RestoreCode would affect from this turn
+	FileCount       int      `json:"fileCount"` // full cumulative file count, including entries omitted from Files
+	FilesTruncated  bool     `json:"filesTruncated,omitempty"`
+	TurnFileCount   int      `json:"turnFileCount"` // files changed during this turn only
+	Time            int64    `json:"time"`          // unix milliseconds
+	CanCode         bool     `json:"canCode"`
+	CanConversation bool     `json:"canConversation"`
+}
+
+const checkpointFilePreviewLimit = 60
+
+// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
+func (a *App) Checkpoints() []CheckpointMeta {
+	return a.CheckpointsForTab("")
+}
+
+func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
+	a.mu.RLock()
+	var ctrl control.SessionAPI
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return []CheckpointMeta{}
+	}
+	metas := ctrl.Checkpoints()
+	out := make([]CheckpointMeta, 0, len(metas))
+	for _, m := range metas {
+		out = append(out, CheckpointMeta{
+			Turn:            m.Turn,
+			Prompt:          m.Prompt,
+			Files:           m.Paths,
+			TurnFileCount:   len(m.Paths),
+			Time:            m.Time.UnixMilli(),
+			CanCode:         len(m.Paths) > 0,
+			CanConversation: ctrl.CheckpointHasBoundary(m.Turn),
+		})
+	}
+	// RestoreCode(turn) reverts every file touched in this turn or any later one, so
+	// a turn can rewind code even when it changed no files itself — as long as a
+	// later turn did. Propagate CanCode backwards over the oldest-first list.
+	// Also propagate the cumulative unique file count so the UI shows how many
+	// files RestoreCode would actually affect from this turn.
+	hasCodeAfter := false
+	codeFileSet := make(map[string]bool, len(metas)*2)
+	codeFilePreview := []string{}
+	for i := len(out) - 1; i >= 0; i-- {
+		if len(out[i].Files) > 0 {
+			hasCodeAfter = true
+		}
+		for _, f := range out[i].Files {
+			if codeFileSet[f] {
+				continue
+			}
+			codeFileSet[f] = true
+			codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
+		}
+		out[i].CanCode = hasCodeAfter
+		out[i].FileCount = len(codeFileSet)
+		out[i].Files = append([]string{}, codeFilePreview...)
+		out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
+	}
+	return out
+}
+
+func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
+	if limit <= 0 || path == "" {
+		return preview
+	}
+	idx := sort.SearchStrings(preview, path)
+	if idx < len(preview) && preview[idx] == path {
+		return preview
+	}
+	if len(preview) < limit {
+		preview = append(preview, "")
+		copy(preview[idx+1:], preview[idx:])
+		preview[idx] = path
+		return preview
+	}
+	if idx >= limit {
+		return preview
+	}
+	copy(preview[idx+1:], preview[idx:limit-1])
+	preview[idx] = path
+	return preview
+}
+
+// ToolResultForTab returns the full arguments and output for one tool call that
+// were elided from the frontend's in-memory items[] for memory efficiency. The
+// caller (frontend ToolCard) loads this on demand when the user expands a
+// collapsed tool card. Returns nil when the tool ID is not found.
+func (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {
+	a.mu.RLock()
+	var ctrl control.SessionAPI
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.ToolResult(toolID)
+}
+
+// Rewind restores the session to the start of turn. scope is "code",
+// "conversation", or "both" (anything else is treated as "both"). The frontend
+// re-reads History after this resolves.
+func (a *App) Rewind(turn int, scope string) error {
+	return a.RewindForTab("", turn, scope)
+}
+
+// RewindForTab rewinds the requested tab instead of resolving the active tab at
+// execution time, which may have changed after frontend confirmation.
+func (a *App) RewindForTab(tabID string, turn int, scope string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	s := control.RewindBoth
+	switch scope {
+	case "code":
+		s = control.RewindCode
+	case "conversation":
+		s = control.RewindConversation
+	}
+	return ctrl.Rewind(turn, s)
+}
+
+// Fork branches the conversation at the start of turn into a new session tab
+// (preserving the current tab), keeping code intact, and switches to the new tab.
+func (a *App) Fork(turn int) (TabMeta, error) {
+	return a.ForkForTab("", turn)
+}
+
+// ForkForTab forks the requested source tab even if focus changes before the
+// backend begins processing the request. The fork becomes active only while the
+// source tab still owns focus, so a later tab selection remains authoritative.
+func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
+	sourceTab, ctrl := a.tabAndCtrlByID(tabID)
+	if sourceTab == nil || ctrl == nil {
+		return TabMeta{}, nil
+	}
+	if a.tabIsReadOnly(sourceTab) {
+		return TabMeta{}, readOnlyChannelErr()
+	}
+
+	if err := a.ensureTabControllerWorkspace(sourceTab); err != nil {
+		return TabMeta{}, err
+	}
+	a.mu.RLock()
+	if a.tabs[sourceTab.ID] != sourceTab || sourceTab.Ctrl == nil {
+		a.mu.RUnlock()
+		return TabMeta{}, nil
+	}
+	ctrl = sourceTab.Ctrl
+	scope := sourceTab.Scope
+	workspaceRoot := sourceTab.WorkspaceRoot
+	sourceTitle := sourceTab.TopicTitle
+	model := sourceTab.model
+	effort := cloneStringPtr(sourceTab.effort)
+	mode := currentTabMode(sourceTab)
+	toolApprovalMode := currentTabToolApprovalMode(sourceTab)
+	disabledMCP := cloneServerViewMap(sourceTab.disabledMCP)
+	mcpOrder := append([]string(nil), sourceTab.mcpOrder...)
+	a.mu.RUnlock()
+
+	newPath, err := ctrl.ForkSession(turn, "")
+	if err != nil {
+		return TabMeta{}, err
+	}
+	topicID := newTopicID()
+	topicTitle := forkTopicTitle(sourceTitle)
+	titleRoot := workspaceRoot
+	if scope == "global" {
+		titleRoot = ""
+	}
+	if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {
+		return TabMeta{}, err
+	}
+	m, _ := agent.EnsureBranchMeta(newPath)
+	m.Scope = scope
+	m.WorkspaceRoot = workspaceRoot
+	m.TopicID = topicID
+	m.TopicTitle = topicTitle
+	if err := agent.SaveBranchMeta(newPath, m); err != nil {
+		return TabMeta{}, err
+	}
+	invalidateTopicSessionIndexForPath(newPath)
+
+	a.mu.Lock()
+	newTabID := a.newUniqueTabIDLocked()
+	tab := &WorkspaceTab{
+		ID:               newTabID,
+		Scope:            scope,
+		WorkspaceRoot:    workspaceRoot,
+		TopicID:          topicID,
+		TopicTitle:       topicTitle,
+		SessionPath:      newPath,
+		model:            model,
+		effort:           effort,
+		mode:             mode,
+		toolApprovalMode: toolApprovalMode,
+		disabledMCP:      disabledMCP,
+		mcpOrder:         mcpOrder,
+	}
+	tab.sink = &tabEventSink{tabID: newTabID, app: a}
+	a.tabs[newTabID] = tab
+	a.tabOrder = append(a.tabOrder, newTabID)
+	activateFork := a.activeTabID == sourceTab.ID
+	if activateFork {
+		a.activeTabID = newTabID
+	}
+	a.saveTabsLocked()
+	meta := a.tabMeta(tab, activateFork)
+	a.mu.Unlock()
+
+	a.emitProjectTreeChanged()
+	a.startTabControllerBuild(tab)
+	return meta, nil
+}
+
+// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start
+// of turn into one summary (Claude Code's "summarize from/up to here"), keeping
+// code intact. The frontend re-reads History after this resolves.
+func (a *App) SummarizeFrom(turn int) error {
+	return a.SummarizeFromForTab("", turn)
+}
+
+func (a *App) SummarizeFromForTab(tabID string, turn int) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.SummarizeFrom(a.ctx, turn)
+}
+
+func (a *App) SummarizeUpTo(turn int) error {
+	return a.SummarizeUpToForTab("", turn)
+}
+
+func (a *App) SummarizeUpToForTab(tabID string, turn int) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.SummarizeUpTo(a.ctx, turn)
+}
+
+// SessionMeta summarises one saved session for the history panel.
+type SessionMeta struct {
+	Path           string `json:"path"`
+	Preview        string `json:"preview"`         // first user message
+	Title          string `json:"title,omitempty"` // user-chosen name, when set (overrides preview)
+	Turns          int    `json:"turns"`
+	CreatedAt      int64  `json:"createdAt"`      // unix milliseconds
+	LastActivityAt int64  `json:"lastActivityAt"` // unix milliseconds
+	ModTime        int64  `json:"modTime"`        // compatibility alias for lastActivityAt
+	DeletedAt      int64  `json:"deletedAt,omitempty"`
+	Current        bool   `json:"current"`
+	Open           bool   `json:"open"`
+	Scope          string `json:"scope,omitempty"`
+	WorkspaceRoot  string `json:"workspaceRoot,omitempty"`
+	TopicID        string `json:"topicId,omitempty"`
+	TopicTitle     string `json:"topicTitle,omitempty"`
+	Kind           string `json:"kind,omitempty"` // "channel" for external IM transcripts
+	Channel        string `json:"channel,omitempty"`
+	ChannelLabel   string `json:"channelLabel,omitempty"`
+	RemoteID       string `json:"remoteId,omitempty"`
+	ChatType       string `json:"chatType,omitempty"`
+	UserID         string `json:"userId,omitempty"`
+	ThreadID       string `json:"threadId,omitempty"`
+	SessionSource  string `json:"sessionSource,omitempty"`
+	Recovered      bool   `json:"recovered,omitempty"`    // created by conflict recovery, including an adopted/continued branch
+	RecoveryCopy   bool   `json:"recoveryCopy,omitempty"` // actual branch content is unchanged and covered by its parent
+}
+
+type channelSessionRoute struct {
+	channel       string
+	channelLabel  string
+	remoteID      string
+	chatType      string
+	userID        string
+	threadID      string
+	sessionSource string
+}
+
+type WorkspaceMeta struct {
+	Path    string `json:"path"`
+	Name    string `json:"name"`
+	Current bool   `json:"current"`
+}
+
+func controllerSessionDir(ctrl control.SessionAPI) string {
+	if ctrl != nil {
+		if dir := ctrl.SessionDir(); dir != "" {
+			return dir
+		}
+	}
+	return desktopSessionDir("")
+}
+
+func tabSessionDir(tab *WorkspaceTab) string {
+	if tab != nil {
+		if tab.WorkspaceRoot != "" {
+			return desktopSessionDir(tab.WorkspaceRoot)
+		}
+		if tab.Ctrl != nil {
+			if dir := tab.Ctrl.SessionDir(); dir != "" {
+				return dir
+			}
+		}
+	}
+	return desktopSessionDir("")
+}
+
+func tabRuntimeSessionDir(tab *WorkspaceTab) string {
+	if tab != nil && tab.Ctrl != nil {
+		if dir, ok := safeControllerSessionDir(tab.Ctrl); ok && strings.TrimSpace(dir) != "" {
+			if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
+				if _, _, err := validateSessionPath(dir, path); err == nil {
+					return dir
+				}
+			} else {
+				return dir
+			}
+		}
+	}
+	return tabSessionDir(tab)
+}
+
+func (a *App) activeSessionDir() string {
+	tab := a.activeTab()
+	if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
+		return filepath.Dir(path)
+	}
+	if tab != nil && tab.Ctrl != nil {
+		return tabRuntimeSessionDir(tab)
+	}
+	return tabSessionDir(tab)
+}
+
+// ListSessions returns the saved sessions newest-first for the history panel,
+// marking the one the current conversation is writing to and attaching any
+// user-chosen titles.
+func (a *App) ListSessions() []SessionMeta {
+	dir := a.activeSessionDir()
+	infos, err := agent.ListSessions(dir)
+	if err != nil {
+		return []SessionMeta{}
+	}
+	open := a.openSessionPaths(dir)
+	protectedDisplays := make(map[string]struct{}, len(open))
+	for path := range open {
+		if key := filepath.Base(path); store.IsSessionTranscriptName(key) {
+			protectedDisplays[key] = struct{}{}
+		}
+	}
+	_ = pruneSessionDisplays(dir, protectedDisplays)
+	titles := loadSessionTitles(dir)
+	channelRoutes := channelSessionRoutesForDir(dir)
+	active := a.activeSessionPath(dir)
+	out := make([]SessionMeta, 0, len(infos))
+	for _, s := range infos {
+		_, isOpen := open[s.Path]
+		title := strings.TrimSpace(s.CustomTitle)
+		if title == "" {
+			title = titles[filepath.Base(s.Path)]
+		}
+		meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0, dir)
+		if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {
+			applyChannelSessionRoute(&meta, route)
+		}
+		out = append(out, meta)
+	}
+	return out
+}
+
+// ListTrashedSessions returns sessions that were moved to the local trash,
+// newest-deleted first. These can be previewed, restored, or permanently purged.
+func (a *App) ListTrashedSessions() []SessionMeta {
+	out := []SessionMeta{}
+	for _, dir := range a.knownSessionDirs() {
+		paths, err := listTrashedSessionFiles(dir)
+		if err != nil {
+			continue
+		}
+		titles := loadSessionTitles(dir)
+		for _, path := range paths {
+			infos, err := agent.ListSessions(filepath.Dir(path))
+			if err != nil || len(infos) == 0 {
+				continue
+			}
+			deletedAt := trashedSessionDeletedAt(path)
+			title := strings.TrimSpace(infos[0].CustomTitle)
+			if title == "" {
+				title = titles[filepath.Base(path)]
+			}
+			out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt, dir))
+		}
+	}
+	sort.Slice(out, func(i, j int) bool {
+		if out[i].DeletedAt == out[j].DeletedAt {
+			return out[i].LastActivityAt > out[j].LastActivityAt
+		}
+		return out[i].DeletedAt > out[j].DeletedAt
+	})
+	return out
+}
+
+func (a *App) trashedSessionDir(path string) (string, error) {
+	for _, dir := range a.knownSessionDirs() {
+		if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {
+			return dir, nil
+		}
+	}
+	return "", fmt.Errorf("trashed session path outside known session dirs: %s", path)
+}
+
+func (a *App) sessionDirForPath(path string) (string, string, error) {
+	for _, dir := range a.knownSessionDirs() {
+		sessionPath, _, err := validateSessionPath(dir, path)
+		if err == nil {
+			return dir, sessionPath, nil
+		}
+	}
+	return "", "", fmt.Errorf("session path outside known session dirs: %s", path)
+}
+
+func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
+	return SessionMeta{
+		Path:           s.Path,
+		Preview:        s.Preview,
+		Title:          title,
+		Turns:          s.Turns,
+		CreatedAt:      s.CreatedAt.UnixMilli(),
+		LastActivityAt: s.LastActivityAt.UnixMilli(),
+		ModTime:        s.LastActivityAt.UnixMilli(),
+		DeletedAt:      deletedAt,
+		Current:        current,
+		Open:           open,
+		Scope:          s.Scope,
+		WorkspaceRoot:  s.WorkspaceRoot,
+		TopicID:        s.TopicID,
+		TopicTitle:     s.TopicTitle,
+		Recovered:      sessionInfoIsAutomaticRecovery(s),
+		RecoveryCopy:   sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir),
+	}
+}
+
+func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {
+	if meta == nil {
+		return
+	}
+	meta.Kind = "channel"
+	meta.Channel = route.channel
+	meta.ChannelLabel = route.channelLabel
+	meta.RemoteID = route.remoteID
+	meta.ChatType = route.chatType
+	meta.UserID = route.userID
+	meta.ThreadID = route.threadID
+	meta.SessionSource = route.sessionSource
+}
+
+func channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {
+	userPath := config.UserConfigPath()
+	if strings.TrimSpace(userPath) == "" {
+		return nil
+	}
+	cfg := config.LoadForEdit(userPath)
+	out := map[string]channelSessionRoute{}
+	for _, conn := range cfg.Bot.Connections {
+		channel := strings.TrimSpace(conn.Provider)
+		if channel == "" {
+			continue
+		}
+		channelLabel := strings.TrimSpace(conn.Label)
+		if channelLabel == "" {
+			channelLabel = channelDisplayName(channel, conn.Domain)
+		}
+		for _, mapping := range conn.SessionMappings {
+			if strings.TrimSpace(mapping.SessionSource) != "auto" {
+				continue
+			}
+			sessionPath := botSessionPathTarget(mapping.SessionID)
+			if sessionPath == "" {
+				continue
+			}
+			validPath, _, err := validateSessionPath(dir, sessionPath)
+			if err != nil {
+				continue
+			}
+			key := sessionRuntimeKey(validPath)
+			if key == "" {
+				continue
+			}
+			out[key] = channelSessionRoute{
+				channel:       channel,
+				channelLabel:  channelLabel,
+				remoteID:      strings.TrimSpace(mapping.RemoteID),
+				chatType:      strings.TrimSpace(mapping.ChatType),
+				userID:        strings.TrimSpace(mapping.UserID),
+				threadID:      strings.TrimSpace(mapping.ThreadID),
+				sessionSource: strings.TrimSpace(mapping.SessionSource),
+			}
+		}
+	}
+	if len(out) == 0 {
+		return nil
+	}
+	return out
+}
+
+func botSessionPathTarget(sessionID string) string {
+	sessionID = strings.TrimSpace(sessionID)
+	if sessionID == "" {
+		return ""
+	}
+	if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
+		return strings.TrimSpace(sessionID[5:])
+	}
+	if strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~") {
+		return sessionID
+	}
+	return ""
+}
+
+func channelDisplayName(provider, domain string) string {
+	provider = strings.TrimSpace(provider)
+	domain = strings.TrimSpace(domain)
+	switch provider {
+	case "feishu":
+		if strings.EqualFold(domain, "lark") {
+			return "Lark"
+		}
+		return "Feishu"
+	case "weixin":
+		return "WeChat"
+	case "qq":
+		return "QQ"
+	default:
+		return provider
+	}
+}
+
+// DeleteSession moves a saved session to the local trash. If the session still
+// has an in-process runtime, the runtime is cancelled and removed first so
+// autosave cannot recreate or append to the deleted file later.
+func (a *App) DeleteSession(path string) error {
+	return friendlySessionFileError(a.deleteSession(path, false))
+}
+
+// DeleteRecoveryCopy is the guarded bulk-cleanup path. The frontend's copy
+// marker is only a hint; the backend re-reads the branch and parent immediately
+// before changing runtime state or moving any files.
+func (a *App) DeleteRecoveryCopy(path string) error {
+	return friendlySessionFileError(a.deleteSession(path, true))
+}
+
+var errRecoveryCopyNotRedundant = errors.New("recovery session contains content not preserved by its parent")
+
+func (a *App) deleteSession(path string, requireRedundantRecovery bool) error {
+	dir := a.activeSessionDir()
+	sessionPath, key, err := validateSessionPath(dir, path)
+	if err != nil {
+		var foundErr error
+		if dir, sessionPath, foundErr = a.sessionDirForPath(path); foundErr != nil {
+			return err
+		}
+		key = filepath.Base(sessionPath)
+	}
+	if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
+		return err
+	}
+	var fallback fallbackRuntimeTarget
+	if err := func() error {
+		a.sessionRemovalMu.Lock()
+		defer a.sessionRemovalMu.Unlock()
+		if requireRedundantRecovery && !agent.RecoveryBranchCoveredByParent(sessionPath, dir) {
+			return errRecoveryCopyNotRedundant
+		}
+
+		removed, nextFallback := a.removeSessionRuntimeBindings(dir, sessionPath)
+		fallback = nextFallback
+		if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
+			a.closeRemovedSessionRuntimes(removed)
+			return err
+		}
+		closedRemoved := map[control.SessionAPI]bool{}
+		destroys := a.destroyHandlesForSession(dir, sessionPath, removed)
+		teardownTimedOut := waitDestroyHandles(destroys)
+		a.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)
+		if teardownTimedOut {
+			if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
+				a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
+				return err
+			}
+			go delayedDesktopSessionTrash(dir, sessionPath, key, destroys)
+		} else {
+			err = trashSessionArtifacts(dir, sessionPath, key)
+			finishDestroyHandles(destroys)
+			if err != nil {
+				a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
+				return err
+			}
+		}
+		a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
+		return nil
+	}(); err != nil {
+		return err
+	}
+	if err := botruntime.ForgetAutoSessionMappingsForPath(sessionPath); err != nil {
+		slog.Warn("desktop: failed to clear auto bot session mapping", "err", err)
+	}
+	if fallback.needs {
+		fallback = a.sessionDeleteFallbackTarget(fallback)
+		if err := a.openFallbackRuntime(fallback); err != nil {
+			return err
+		}
+	}
+	a.emitProjectTreeChanged()
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+type removedSessionRuntime struct {
+	tab           *WorkspaceTab
+	ctrl          control.SessionAPI
+	sink          *tabEventSink
+	sessionDir    string
+	sessionPath   string
+	scope         string
+	workspaceRoot string
+	topicID       string
+	readOnly      bool
+}
+
+type fallbackRuntimeTarget struct {
+	needs         bool
+	scope         string
+	workspaceRoot string
+	topicID       string
+}
+
+func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
+	var removed []removedSessionRuntime
+	var fallback fallbackRuntimeTarget
+
+	a.mu.Lock()
+	for id, tab := range a.tabs {
+		if !tabMatchesSession(tab, dir, sessionPath) {
+			continue
+		}
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.tabs, id)
+		a.removeTabOrderLocked(id)
+		if a.activeTabID == id {
+			a.activeTabID = ""
+		}
+	}
+	for key, tab := range a.detachedSessions {
+		if !tabMatchesSession(tab, dir, sessionPath) {
+			continue
+		}
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.detachedSessions, key)
+	}
+	if a.activeTabID == "" && len(a.tabOrder) > 0 {
+		a.activeTabID = a.tabOrder[0]
+	}
+	fallback.needs = len(removed) > 0 && len(a.tabs) == 0
+	dir, entries, activeID, version := a.saveTabsCollectLocked()
+	a.mu.Unlock()
+
+	a.saveTabsWrite(dir, entries, activeID, version)
+
+	return removed, fallback
+}
+
+func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallbackRuntimeTarget {
+	topicID := strings.TrimSpace(target.topicID)
+	if topicID == "" {
+		return target
+	}
+	if path, _ := a.findTopicContentSessionForTarget(target.scope, target.workspaceRoot, topicID); path != "" {
+		return target
+	}
+	target.topicID = ""
+	return target
+}
+
+func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
+	var removed []removedSessionRuntime
+	var fallback fallbackRuntimeTarget
+
+	a.mu.Lock()
+	for id, tab := range a.tabs {
+		if tab == nil || tab.TopicID != topicID {
+			continue
+		}
+		sessionDir := tabRuntimeSessionDir(tab)
+		sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.tabs, id)
+		a.removeTabOrderLocked(id)
+		if a.activeTabID == id {
+			a.activeTabID = ""
+		}
+	}
+	for key, tab := range a.detachedSessions {
+		if tab == nil || tab.TopicID != topicID {
+			continue
+		}
+		sessionDir := tabRuntimeSessionDir(tab)
+		sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.detachedSessions, key)
+	}
+	if a.activeTabID == "" && len(a.tabOrder) > 0 {
+		a.activeTabID = a.tabOrder[0]
+	}
+	fallback.needs = len(removed) > 0 && len(a.tabs) == 0
+	dir, entries, activeID, version := a.saveTabsCollectLocked()
+	a.mu.Unlock()
+
+	a.saveTabsWrite(dir, entries, activeID, version)
+
+	return removed, fallback
+}
+
+func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
+	return removedSessionRuntime{
+		tab:           tab,
+		ctrl:          tab.Ctrl,
+		sink:          tab.sink,
+		sessionDir:    dir,
+		sessionPath:   sessionPath,
+		scope:         tab.Scope,
+		workspaceRoot: tab.WorkspaceRoot,
+		topicID:       tab.TopicID,
+		readOnly:      tab.ReadOnly,
+	}
+}
+
+func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {
+	if tab == nil {
+		return false
+	}
+	currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())
+	if err == nil && currentPath == sessionPath {
+		return true
+	}
+	if tabRuntimeSessionDir(tab) != dir {
+		return false
+	}
+	currentPath, _, err = validateSessionPath(dir, tab.currentSessionPath())
+	return err == nil && currentPath == sessionPath
+}
+
+func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {
+	for _, item := range removed {
+		if item.sink != nil {
+			item.sink.clearContext()
+		}
+		if item.ctrl == nil {
+			continue
+		}
+		if item.ctrl.Running() {
+			item.ctrl.Cancel()
+			if err := waitControllerStopped(item.ctrl); err != nil {
+				return err
+			}
+		}
+		if item.readOnly {
+			continue
+		}
+		if err := item.ctrl.Snapshot(); err != nil {
+			if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
+				return err
+			}
+			slog.Warn("desktop: skipping stale runtime snapshot before removing session",
+				"session", item.sessionPath, "err", err)
+		}
+		item.ctrl.SetSessionPath("")
+		a.quiesceTabAutosave(item.tab)
+	}
+	return nil
+}
+
+func waitControllerStopped(ctrl control.SessionAPI) error {
+	deadline := time.Now().Add(5 * time.Second)
+	for ctrl.Running() {
+		if time.Now().After(deadline) {
+			return fmt.Errorf("timed out waiting for cancelled session work to stop")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	return nil
+}
+
+func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {
+	destroys := a.beginDestroySessionJobs(dir, sessionPath)
+	for _, item := range removed {
+		if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {
+			continue
+		}
+		destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))
+	}
+	return destroys
+}
+
+func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
+	timedOut := false
+	for _, destroy := range destroys {
+		if destroy.Wait != nil {
+			if destroy.Wait().HasTimedOut() {
+				timedOut = true
+			}
+		}
+	}
+	return timedOut
+}
+
+func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
+	for _, destroy := range destroys {
+		if destroy.WaitAll != nil {
+			destroy.WaitAll()
+		}
+	}
+}
+
+func finishDestroyHandles(destroys []control.SessionDestroyHandle) {
+	for _, destroy := range destroys {
+		if destroy.Finish != nil {
+			destroy.Finish()
+		}
+	}
+}
+
+func delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {
+	waitAllDestroyHandles(destroys)
+	if err := removeDesktopSessionArtifacts(path); err != nil {
+		slog.Warn("desktop: delayed session cleanup failed", "path", path, "err", err)
+	}
+	finishDestroyHandles(destroys)
+}
+
+func delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {
+	waitAllDestroyHandles(destroys)
+	if err := trashSessionArtifacts(dir, sessionPath, key); err != nil {
+		slog.Warn("desktop: delayed session trash failed", "path", sessionPath, "err", err)
+	}
+	finishDestroyHandles(destroys)
+}
+
+func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {
+	a.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})
+}
+
+func (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {
+	releasedTabs := map[*WorkspaceTab]bool{}
+	for _, item := range removed {
+		if item.sessionDir != dir || item.sessionPath != sessionPath {
+			continue
+		}
+		a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
+	}
+}
+
+func (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
+	releasedTabs := map[*WorkspaceTab]bool{}
+	for _, item := range removed {
+		a.closeRemovedSessionRuntime(item, closed, releasedTabs, false)
+	}
+}
+
+func (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
+	releasedTabs := map[*WorkspaceTab]bool{}
+	for _, item := range removed {
+		a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
+	}
+}
+
+func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {
+	if item.tab != nil {
+		if releasedTabs == nil || !releasedTabs[item.tab] {
+			if releasedTabs != nil {
+				releasedTabs[item.tab] = true
+			}
+			a.releaseTabSharedHost(item.tab)
+			item.tab.releaseSessionLease()
+		}
+	}
+	if item.ctrl == nil {
+		return
+	}
+	if closed == nil {
+		closed = map[control.SessionAPI]bool{}
+	}
+	if closed[item.ctrl] {
+		return
+	}
+	closed[item.ctrl] = true
+	if afterDestroy {
+		item.ctrl.CloseAfterDestroy()
+		return
+	}
+	item.ctrl.Close()
+}
+
+func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
+	scope := target.scope
+	root := target.workspaceRoot
+	topicID := strings.TrimSpace(target.topicID)
+	if scope == "global" {
+		root = ""
+	}
+	if topicID == "" {
+		return a.openTransientBlankRuntime(scope, root)
+	}
+	var err error
+	if a.singleSurfaceLayoutEnabled() {
+		_, err = a.ActivateTopic(scope, root, topicID, "")
+	} else if scope == "global" {
+		_, err = a.OpenGlobalTab(topicID)
+	} else {
+		_, err = a.OpenProjectTab(root, topicID)
+	}
+	return err
+}
+
+func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
+	scope = strings.TrimSpace(scope)
+	if scope != "project" {
+		scope = "global"
+	}
+	actualRoot := ""
+	if scope == "project" {
+		workspaceRoot = normalizeProjectRoot(workspaceRoot)
+		if workspaceRoot == "" {
+			return fmt.Errorf("workspaceRoot is required")
+		}
+		saveWorkspace(workspaceRoot)
+		a.registerProjectRoot(workspaceRoot)
+		actualRoot = workspaceRoot
+	} else {
+		actualRoot = globalWorkspaceRoot()
+		if err := os.MkdirAll(actualRoot, 0o755); err != nil {
+			return fmt.Errorf("create global workspace: %w", err)
+		}
+	}
+
+	model, toolApprovalMode := desktopNewSessionDefaults()
+	sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
+	if err != nil {
+		return err
+	}
+	tab := &WorkspaceTab{
+		Scope:            scope,
+		WorkspaceRoot:    actualRoot,
+		TopicTitle:       defaultTopicTitle,
+		SessionPath:      sessionPath,
+		model:            model,
+		tokenMode:        boot.TokenModeFull,
+		mode:             tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
+		toolApprovalMode: toolApprovalMode,
+		disabledMCP:      map[string]ServerView{},
+	}
+	a.mu.Lock()
+	tab.ID = a.newUniqueTabIDLocked()
+	tab.sink = &tabEventSink{tabID: tab.ID, app: a}
+	a.tabs[tab.ID] = tab
+	a.tabOrder = append(a.tabOrder, tab.ID)
+	a.activeTabID = tab.ID
+	a.saveTabsLocked()
+	a.mu.Unlock()
+
+	a.startTabControllerBuild(tab)
+	return nil
+}
+
+func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	var destroys []control.SessionDestroyHandle
+	for _, tab := range a.runtimeTabsLocked() {
+		if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
+			continue
+		}
+		destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))
+	}
+	return destroys
+}
+
+func (a *App) openSessionPaths(dir string) map[string]struct{} {
+	a.mu.RLock()
+	paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))
+	for _, tab := range a.runtimeTabsLocked() {
+		if tab != nil {
+			paths = append(paths, tab.currentSessionPath())
+		}
+	}
+	a.mu.RUnlock()
+
+	out := make(map[string]struct{}, len(paths))
+	for _, path := range paths {
+		currentPath, _, err := validateSessionPath(dir, path)
+		if err == nil {
+			out[currentPath] = struct{}{}
+		}
+	}
+	return out
+}
+
+func (a *App) activeSessionPath(dir string) string {
+	a.mu.RLock()
+	var path string
+	if tab := a.tabs[a.activeTabID]; tab != nil {
+		path = tab.currentSessionPath()
+	}
+	a.mu.RUnlock()
+	currentPath, _, err := validateSessionPath(dir, path)
+	if err != nil {
+		return ""
+	}
+	return currentPath
+}
+
+// RestoreSession moves a trashed session back into the saved-session list.
+func (a *App) RestoreSession(path string) error {
+	return friendlySessionFileError(a.restoreSession(path))
+}
+
+func (a *App) restoreSession(path string) error {
+	dir, err := a.trashedSessionDir(path)
+	if err != nil {
+		return err
+	}
+	_, key, _, err := validateTrashedSessionPath(dir, path)
+	if err != nil {
+		return err
+	}
+	// The destroying/open checks and the trash-entry move must not interleave
+	// with DeleteSession/TrashTopic trashing the same entry.
+	a.sessionRemovalMu.Lock()
+	defer a.sessionRemovalMu.Unlock()
+	target := filepath.Join(dir, key)
+	if a.sessionDestroying(dir, target) {
+		return fmt.Errorf("session cleanup is still in progress: %s", key)
+	}
+	if a.sessionOpen(dir, target) {
+		return fmt.Errorf("session is open: %s", key)
+	}
+	if err := restoreTrashedSessionFile(dir, path); err != nil {
+		return err
+	}
+	if err := restoreSessionTopicIndex(dir, target); err != nil {
+		return err
+	}
+	a.emitProjectTreeChanged()
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+func (a *App) sessionDestroying(dir, sessionPath string) bool {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	for _, tab := range a.runtimeTabsLocked() {
+		if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
+			continue
+		}
+		if tab.Ctrl.IsDestroyingSession(sessionPath) {
+			return true
+		}
+	}
+	return false
+}
+
+func (a *App) sessionOpen(dir, sessionPath string) bool {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	for _, tab := range a.runtimeTabsLocked() {
+		if tabMatchesSession(tab, dir, sessionPath) {
+			return true
+		}
+	}
+	return false
+}
+
+// PurgeTrashedSession permanently removes a trashed session and its title/display
+// sidecars.
+func (a *App) PurgeTrashedSession(path string) error {
+	return friendlySessionFileError(a.purgeTrashedSession(path, false))
+}
+
+// PurgeRecoveryCopy is the guarded permanent-cleanup path. A trashed branch is
+// rechecked against its live parent; missing, stale, or divergent data is kept.
+func (a *App) PurgeRecoveryCopy(path string) error {
+	return friendlySessionFileError(a.purgeTrashedSession(path, true))
+}
+
+func (a *App) purgeTrashedSession(path string, requireRedundantRecovery bool) error {
+	dir, err := a.trashedSessionDir(path)
+	if err != nil {
+		return err
+	}
+	a.sessionRemovalMu.Lock()
+	defer a.sessionRemovalMu.Unlock()
+	var parentGuard *agent.SessionRemovalGuard
+	if requireRedundantRecovery {
+		parentGuard, err = agent.TryAcquireRecoveryParentGuard(path, dir)
+		if err != nil {
+			switch {
+			case errors.Is(err, agent.ErrRecoveryBranchNotCovered):
+				return errRecoveryCopyNotRedundant
+			case errors.Is(err, agent.ErrSessionLeaseHeld):
+				return errSessionBusyElsewhere
+			default:
+				return err
+			}
+		}
+		defer parentGuard.Release()
+	}
+	if err := purgeTrashedSessionFile(dir, path); err != nil {
+		return err
+	}
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+// RenameSession sets a custom display name for a session (empty clears it back to
+// the preview). The transcript file is unchanged; the canonical name lives in
+// the branch meta sidecar, with the legacy .titles.json map kept as a
+// compatibility write-through for older desktop data paths.
+func (a *App) RenameSession(path, title string) error {
+	dir := a.activeSessionDir()
+	sessionPath, _, err := validateSessionPath(dir, path)
+	if err != nil {
+		return err
+	}
+	if err := agent.RenameSession(sessionPath, title); err != nil {
+		return err
+	}
+	if err := setSessionTitle(dir, sessionPath, title); err != nil {
+		return err
+	}
+	a.invalidatePromptHistoryCache()
+	a.emitProjectTreeChanged()
+	return nil
+}
+
+// ResumeSession snapshots the current conversation, then loads the session at
+// path and continues it on the active tab. The model and working folder are
+// unchanged; only the transcript is swapped. Returns the resumed messages for
+// the frontend to render.
+func (a *App) ResumeSession(path string) ([]HistoryMessage, error) {
+	return a.ResumeSessionForTab("", path)
+}
+
+func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) {
+	return a.ResumeSessionPageForTab("", path, limit)
+}
+
+func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return HistoryPage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
+		if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+			return HistoryPage{}, err
+		}
+	}
+	a.setTabReadOnly(tab.ID, false)
+	return a.HistoryPageForTab(tab.ID, 0, limit), nil
+}
+
+// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session
+// path is a runtime identity, so changing to a different path must replace the
+// tab's controller binding rather than mutating the current controller in place.
+func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return []HistoryMessage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return nil, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return nil, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
+		a.setTabReadOnly(tab.ID, false)
+		return a.HistoryForTab(tabID), nil
+	}
+
+	if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+		return nil, err
+	}
+	a.setTabReadOnly(tab.ID, false)
+	return a.HistoryForTab(tab.ID), nil
+}
+
+func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return []HistoryMessage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return nil, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return nil, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
+		if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+			return nil, err
+		}
+	}
+	a.setTabReadOnly(tab.ID, true)
+	return a.HistoryForTab(tab.ID), nil
+}
+
+func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return HistoryPage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
+		if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+			return HistoryPage{}, err
+		}
+	}
+	a.setTabReadOnly(tab.ID, true)
+	return a.HistoryPageForTab(tab.ID, 0, limit), nil
+}
+
+func (a *App) setTabReadOnly(tabID string, readOnly bool) {
+	a.mu.Lock()
+	if tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {
+		tab.ReadOnly = readOnly
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {
+	sessionPath = canonicalTabSessionPath(sessionPath)
+	if sessionPath == "" {
+		return fmt.Errorf("session path is required")
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return err
+	}
+	return a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)
+}
+
+func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {
+	if tab == nil {
+		return fmt.Errorf("tab is not ready")
+	}
+	sessionPath = canonicalTabSessionPath(sessionPath)
+	if sessionPath == "" {
+		return fmt.Errorf("session path is required")
+	}
+	if agent.IsCleanupPending(sessionPath) {
+		return fmt.Errorf("session is pending cleanup")
+	}
+	if loaded == nil {
+		var err error
+		loaded, err = loadResumableSession(sessionPath)
+		if err != nil {
+			return err
+		}
+	}
+	// Session rebinding is a full detach/close/build/swap of the tab's
+	// controller — the same shape as rebuildSetting/SetModelForTab. Hold the
+	// shared rebuild mutex so a concurrent model/effort/settings rebuild of the
+	// same tab cannot interleave: without it both builds pass the swap-time
+	// identity check, the loser's controller leaks un-closed, and the old
+	// controller can be double-closed.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+
+	// Validate the tab, compare session keys, invalidate any in-flight async
+	// build, and snapshot the controller in ONE a.mu critical section.
+	// runtimeRebuildMu does not cover startTabControllerBuild's goroutine, so
+	// observing ctrl == nil and bumping the generation later would leave a
+	// window where the async build passes its swap-time generation check,
+	// installs its controller after the observation, and the loaded build
+	// below silently overwrites it — leaking the runtime and its shared-host
+	// reference. Bump-before-snapshot makes the snapshot authoritative: after
+	// the bump the async build can only fall into its superseded branches,
+	// which release exactly what it acquired (abandonSupersededBuild).
+	a.mu.Lock()
+	if tab.removed || a.tabs[tab.ID] != tab {
+		a.mu.Unlock()
+		return fmt.Errorf("tab is not ready")
+	}
+	currentPath := ""
+	if tab.Ctrl != nil {
+		currentPath = strings.TrimSpace(tab.Ctrl.SessionPath())
+	}
+	if currentPath == "" {
+		currentPath = strings.TrimSpace(tab.SessionPath)
+	}
+	if sessionRuntimeKey(currentPath) == sessionRuntimeKey(sessionPath) {
+		// Same session: leave any in-flight build alone — resuming the
+		// session a build is already binding must stay a no-op.
+		a.mu.Unlock()
+		return nil
+	}
+	tab.buildGeneration++
+	if tab.buildCancel != nil {
+		tab.buildCancel()
+		tab.buildCancel = nil
+	}
+	ctrl := tab.Ctrl
+	a.mu.Unlock()
+
+	profile := loadTabSessionProfile(sessionPath)
+
+	if ctrl == nil {
+		a.mu.Lock()
+		tab.SessionPath = sessionPath
+		applyTabSessionProfile(tab, profile)
+		tab.Ready = false
+		clearTabStartupError(tab)
+		tab.ActivityStatus = ""
+		tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+		a.saveTabsLocked()
+		a.mu.Unlock()
+		a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})
+		a.mu.RLock()
+		builtCtrl, startupErr := tab.Ctrl, tab.StartupErr
+		a.mu.RUnlock()
+		if builtCtrl == nil {
+			if startupErr != "" {
+				return fmt.Errorf("resume session: %s", startupErr)
+			}
+			return fmt.Errorf("resume session: controller was not built")
+		}
+		return nil
+	}
+
+	if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
+		return err
+	}
+	if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
+		if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
+			return fmt.Errorf("save current session metadata before switching sessions: %w", err)
+		}
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		if !a.detachRuntimeForReplacement(tab) {
+			return fmt.Errorf("current session runtime cannot be detached")
+		}
+	} else {
+		ctrl.Close()
+		tab.releaseSessionLease()
+	}
+
+	a.mu.Lock()
+	// The generation was already bumped (and any async build cancelled) in
+	// the validation section above; only retarget the tab here.
+	tab.Ctrl = nil
+	tab.SessionPath = sessionPath
+	applyTabSessionProfile(tab, profile)
+	tab.Ready = false
+	clearTabStartupError(tab)
+	tab.ActivityStatus = ""
+	tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+	a.saveTabsLocked()
+	a.mu.Unlock()
+
+	a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})
+	a.mu.RLock()
+	builtCtrl, startupErr := tab.Ctrl, tab.StartupErr
+	a.mu.RUnlock()
+	if builtCtrl == nil {
+		if startupErr != "" {
+			return fmt.Errorf("resume session: %s", startupErr)
+		}
+		return fmt.Errorf("resume session: controller was not built")
+	}
+	return nil
+}
+
+func loadResumableSession(sessionPath string) (*agent.Session, error) {
+	if agent.IsCleanupPending(sessionPath) {
+		return nil, fmt.Errorf("session is pending cleanup")
+	}
+	return agent.LoadSession(sessionPath)
+}
+
+// PreviewSession reads a saved session for display only. It does not snapshot or
+// swap the active controller, so the history drawer can call it while a turn runs.
+func (a *App) PreviewSession(path string) ([]HistoryMessage, error) {
+	sessionDir, sessionPath, err := a.sessionDirForPath(path)
+	if err != nil {
+		return nil, err
+	}
+	return previewSessionMessages(sessionDir, sessionPath)
+}
+
+// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next
+// ScanPromptHistory call rebuilds session order and reloads sessions on demand.
+// Called from every session-mutating path: NewSession, ClearSession,
+// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.
+func (a *App) invalidatePromptHistoryCache() {
+	a.promptHistoryMu.Lock()
+	a.promptHistoryTape = nil
+	a.promptHistoryMu.Unlock()
+}
+
+const (
+	promptHistoryPageLimit    = 50
+	promptHistoryMaxPageLimit = 200
+)
+
+type promptHistoryRequest struct {
+	Nonce  string `json:"nonce,omitempty"`
+	Cursor string `json:"cursor,omitempty"`
+	Limit  int    `json:"limit,omitempty"`
+	legacy bool
+}
+
+type promptHistoryCursor struct {
+	Nonce   string `json:"n"`
+	Session int    `json:"s"`
+	Offset  int    `json:"o"`
+}
+
+type promptHistoryTape struct {
+	nonce       string
+	dir         string
+	currentPath string
+	displays    sessionDisplayMap
+	sessions    []promptHistorySessionFile
+	loaded      map[string][]PromptHistoryEntry
+}
+
+// ScanPromptHistory returns the next prompt-history tape segment. The request is
+// a JSON string so the Wails binding stays one-argument while the protocol can
+// carry a cursor and page limit. Older clients may still pass a bare nonce; that
+// path keeps the old cache-hit behavior.
+func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {
+	req := parsePromptHistoryRequest(rawRequest)
+	dir := a.activeSessionDir()
+	sessionPath := a.activeSessionPath(dir)
+
+	a.promptHistoryMu.Lock()
+	tape, err := a.promptHistoryTapeForLocked(dir, sessionPath)
+	if err != nil {
+		a.promptHistoryMu.Unlock()
+		return PromptHistoryResult{}, err
+	}
+	if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce {
+		a.promptHistoryMu.Unlock()
+		return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil
+	}
+	result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))
+	a.promptHistoryMu.Unlock()
+	return result, nil
+}
+
+func parsePromptHistoryRequest(raw string) promptHistoryRequest {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return promptHistoryRequest{}
+	}
+	if strings.HasPrefix(raw, "{") {
+		var req promptHistoryRequest
+		if err := json.Unmarshal([]byte(raw), &req); err == nil {
+			return req
+		}
+	}
+	return promptHistoryRequest{Nonce: raw, legacy: true}
+}
+
+func promptHistoryLimit(limit int) int {
+	if limit <= 0 {
+		return promptHistoryPageLimit
+	}
+	if limit > promptHistoryMaxPageLimit {
+		return promptHistoryMaxPageLimit
+	}
+	return limit
+}
+
+func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {
+	currentPath := ""
+	if path, _, err := validateSessionPath(dir, sessionPath); err == nil {
+		currentPath = path
+	}
+	if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {
+		return a.promptHistoryTape, nil
+	}
+	tape, err := newPromptHistoryTape(dir, currentPath)
+	if err != nil {
+		return nil, err
+	}
+	a.promptHistoryTape = tape
+	return tape, nil
+}
+
+func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {
+	tape, err := newPromptHistoryTape(dir, "")
+	if err != nil {
+		return nil, err
+	}
+	return tape.readAll(), nil
+}
+
+func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {
+	tape := &promptHistoryTape{
+		nonce:       fmt.Sprintf("%d", time.Now().UnixNano()),
+		dir:         dir,
+		currentPath: currentPath,
+		displays:    loadSessionDisplays(dir),
+		loaded:      map[string][]PromptHistoryEntry{},
+	}
+	sessions, err := promptHistorySessionFiles(dir)
+	if err != nil {
+		return nil, err
+	}
+	if currentPath != "" {
+		currentPath = filepath.Clean(currentPath)
+		currentSession := promptHistorySessionFile{}
+		currentIndex := -1
+		for i, session := range sessions {
+			if filepath.Clean(session.path) == currentPath {
+				currentSession = session
+				currentIndex = i
+				break
+			}
+		}
+		if currentIndex >= 0 {
+			sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)
+		} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {
+			sessions = append([]promptHistorySessionFile{{
+				path: currentPath,
+			}}, sessions...)
+		}
+	}
+	tape.sessions = sessions
+	return tape, nil
+}
+
+func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {
+	c := promptHistoryCursor{Nonce: t.nonce}
+	if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {
+		c = decoded
+	}
+	if c.Session < 0 {
+		c.Session = 0
+	}
+	if c.Offset < 0 {
+		c.Offset = 0
+	}
+
+	out := make([]PromptHistoryEntry, 0, limit)
+	sessionIndex := c.Session
+	offset := c.Offset
+	for sessionIndex < len(t.sessions) && len(out) < limit {
+		entries, err := t.entriesForSession(sessionIndex)
+		if err != nil || offset >= len(entries) {
+			sessionIndex++
+			offset = 0
+			continue
+		}
+
+		end := min(len(entries), offset+limit-len(out))
+		out = append(out, entries[offset:end]...)
+		offset = end
+		if offset >= len(entries) && len(out) < limit {
+			sessionIndex++
+			offset = 0
+		}
+	}
+
+	if sessionIndex < len(t.sessions) {
+		if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {
+			sessionIndex++
+			offset = 0
+		}
+	}
+	hasOlder := sessionIndex < len(t.sessions)
+	olderCursor := ""
+	if hasOlder {
+		olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})
+	}
+	return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}
+}
+
+func (t *promptHistoryTape) readAll() []PromptHistoryEntry {
+	out := []PromptHistoryEntry{}
+	cursor := ""
+	for {
+		page := t.readOlder(cursor, promptHistoryMaxPageLimit)
+		out = append(out, page.Entries...)
+		if !page.HasOlder || page.OlderCursor == "" {
+			return out
+		}
+		cursor = page.OlderCursor
+	}
+}
+
+func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {
+	if index < 0 || index >= len(t.sessions) {
+		return nil, nil
+	}
+	path := t.sessions[index].path
+	if entries, ok := t.loaded[path]; ok {
+		return entries, nil
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.loaded[path] = nil
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))
+	if err != nil {
+		t.loaded[path] = nil
+		return nil, err
+	}
+	t.loaded[path] = entries
+	return entries, nil
+}
+
+func encodePromptHistoryCursor(cursor promptHistoryCursor) string {
+	b, err := json.Marshal(cursor)
+	if err != nil {
+		return ""
+	}
+	return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {
+	if strings.TrimSpace(value) == "" {
+		return promptHistoryCursor{}, false
+	}
+	b, err := base64.RawURLEncoding.DecodeString(value)
+	if err != nil {
+		return promptHistoryCursor{}, false
+	}
+	var cursor promptHistoryCursor
+	if err := json.Unmarshal(b, &cursor); err != nil {
+		return promptHistoryCursor{}, false
+	}
+	return cursor, true
+}
+
+func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
+	entries, err := collectPromptHistoryEntries(path, info, resolveUserContent)
+	if err != nil {
+		return nil, err
+	}
+	sortPromptHistoryNewestFirst(entries)
+	return entries, nil
+}
+
+type promptHistorySessionFile struct {
+	path string
+}
+
+func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {
+	infos, err := agent.ListSessionOrder(dir)
+	if err != nil {
+		return nil, err
+	}
+	sessions := make([]promptHistorySessionFile, 0, len(infos))
+	for _, info := range infos {
+		sessions = append(sessions, promptHistorySessionFile{path: info.Path})
+	}
+	return sessions, nil
+}
+
+func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {
+	if a.At != b.At {
+		return a.At > b.At
+	}
+	if a.SessionPath != b.SessionPath {
+		return a.SessionPath > b.SessionPath
+	}
+	return a.Turn > b.Turn
+}
+
+func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {
+	sort.Slice(entries, func(i, j int) bool {
+		return promptHistoryEntryNewer(entries[i], entries[j])
+	})
+}
+
+func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
+	var out []PromptHistoryEntry
+	emit := func(entry PromptHistoryEntry) {
+		out = append(out, entry)
+	}
+	// Sessions with an event log must replay it: the .jsonl checkpoint stops
+	// gaining turns between checkpoints, so scanning it directly would freeze
+	// ↑-recall at each session's last checkpoint.
+	if handled, err := collectEventLogUserPrompts(path, info, resolveUserContent, emit); handled {
+		return out, err
+	}
+	err := collectJSONLUserPrompts(path, info, resolveUserContent, emit)
+	return out, err
+}
+
+func collectEventLogUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) (bool, error) {
+	logPath := store.SessionEventLog(path)
+	if logPath == "" {
+		return false, nil
+	}
+	if logInfo, err := os.Stat(logPath); err != nil || logInfo.IsDir() || logInfo.Size() == 0 {
+		return false, nil
+	}
+	users, err := agent.LoadSessionUserMessages(path)
+	if err != nil {
+		return true, err
+	}
+	fallbackAt := promptHistoryFallbackMillis(path, info)
+	turn := 0
+	for _, user := range users {
+		text := strings.TrimSpace(resolveUserContent(strings.TrimSpace(user.Text)))
+		if text == "" || control.IsSyntheticUserMessage(text) {
+			continue
+		}
+		at := fallbackAt
+		if !user.At.IsZero() {
+			at = user.At.UnixMilli()
+		}
+		emit(PromptHistoryEntry{
+			Text:        text,
+			At:          at,
+			SessionPath: path,
+			Turn:        turn,
+		})
+		turn++
+	}
+	return true, nil
+}
+
+func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {
+	f, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	fallbackAt := promptHistoryFallbackMillis(path, info)
+
+	dec := json.NewDecoder(f)
+	turn := 0
+	for {
+		var rec previewEventRecord
+		if err := dec.Decode(&rec); err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			return nil // partial results are better than none
+		}
+		// Format compatibility:
+		// 1) Legacy event format: {"kind":"user.message","text":"..."}
+		// 2) Early event format:   {"type":"user.message","text":"..."}
+		// 3) Current provider.Message format: {"role":"user","content":"..."}
+		text := ""
+		kindOrType := strings.TrimSpace(rec.Kind)
+		if kindOrType == "" {
+			kindOrType = strings.TrimSpace(rec.Type)
+		}
+		if kindOrType == "user.message" {
+			text = strings.TrimSpace(rec.Text)
+		} else if strings.TrimSpace(rec.Role) == "user" {
+			text = strings.TrimSpace(rec.Content)
+		}
+		if text != "" {
+			text = resolveUserContent(text)
+			text = strings.TrimSpace(text)
+			if text == "" {
+				continue
+			}
+			if control.IsSyntheticUserMessage(text) {
+				continue
+			}
+			at := fallbackAt
+			if eventAt, ok := promptHistoryEventMillis(rec); ok {
+				at = eventAt
+			}
+			entry := PromptHistoryEntry{
+				Text:        text,
+				At:          at,
+				SessionPath: path,
+				Turn:        turn,
+			}
+			emit(entry)
+			turn++
+		}
+	}
+	return nil
+}
+
+func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {
+	if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {
+		return meta.UpdatedAt.UnixMilli()
+	}
+	if info != nil {
+		return info.ModTime().UnixMilli()
+	}
+	return 0
+}
+
+func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {
+	for _, raw := range []json.RawMessage{
+		rec.Time,
+		rec.Timestamp,
+		rec.CreatedAt,
+		rec.CreatedAtSnake,
+		rec.UpdatedAt,
+		rec.UpdatedAtSnake,
+	} {
+		if at, ok := parseJSONTimestampMillis(raw); ok {
+			return at, true
+		}
+	}
+	return 0, false
+}
+
+func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {
+	if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
+		return 0, false
+	}
+
+	var s string
+	if err := json.Unmarshal(raw, &s); err == nil {
+		s = strings.TrimSpace(s)
+		if s == "" {
+			return 0, false
+		}
+		if n, err := strconv.ParseInt(s, 10, 64); err == nil {
+			return normalizeTimestampMillis(n)
+		}
+		if f, err := strconv.ParseFloat(s, 64); err == nil {
+			return normalizeTimestampMillisFloat(f)
+		}
+		if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
+			return t.UnixMilli(), true
+		}
+		return 0, false
+	}
+
+	dec := json.NewDecoder(bytes.NewReader(raw))
+	dec.UseNumber()
+	var n json.Number
+	if err := dec.Decode(&n); err != nil {
+		return 0, false
+	}
+	if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {
+		return normalizeTimestampMillis(i)
+	}
+	if f, err := strconv.ParseFloat(n.String(), 64); err == nil {
+		return normalizeTimestampMillisFloat(f)
+	}
+	return 0, false
+}
+
+func normalizeTimestampMillis(v int64) (int64, bool) {
+	if v <= 0 {
+		return 0, false
+	}
+	switch {
+	case v >= 1_000_000_000_000_000_000:
+		return v / 1_000_000, true // nanoseconds
+	case v >= 1_000_000_000_000_000:
+		return v / 1_000, true // microseconds
+	case v >= 100_000_000_000:
+		return v, true // milliseconds
+	case v >= 1_000_000_000:
+		return v * 1_000, true // seconds
+	default:
+		return 0, false
+	}
+}
+
+func normalizeTimestampMillisFloat(v float64) (int64, bool) {
+	if v <= 0 {
+		return 0, false
+	}
+	switch {
+	case v >= 1_000_000_000_000_000_000:
+		return int64(v / 1_000_000), true
+	case v >= 1_000_000_000_000_000:
+		return int64(v / 1_000), true
+	case v >= 100_000_000_000:
+		return int64(v), true
+	case v >= 1_000_000_000:
+		return int64(v * 1_000), true
+	default:
+		return 0, false
+	}
+}
+
+// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab
+// scoped to that folder. Returns the chosen path ("" if cancelled).
+func (a *App) PickWorkspace() (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	cur, _ := os.Getwd()
+	a.mu.RLock()
+	if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
+		cur = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+		Title:            "Choose working folder",
+		DefaultDirectory: dialogDefaultDirectory(cur),
+	})
+	if err != nil || dir == "" {
+		return "", err
+	}
+	return a.SwitchWorkspace(dir)
+}
+
+func dialogDefaultDirectory(preferred string) string {
+	if dir := nearestExistingDirectory(preferred); dir != "" {
+		return dir
+	}
+	if cwd, err := os.Getwd(); err == nil {
+		if dir := nearestExistingDirectory(cwd); dir != "" {
+			return dir
+		}
+	}
+	if home, err := os.UserHomeDir(); err == nil {
+		if dir := nearestExistingDirectory(home); dir != "" {
+			return dir
+		}
+	}
+	return ""
+}
+
+func nearestExistingDirectory(path string) string {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return ""
+	}
+	if abs, err := filepath.Abs(path); err == nil {
+		path = abs
+	}
+	for {
+		info, err := os.Stat(path)
+		if err == nil {
+			if info.IsDir() {
+				return path
+			}
+			path = filepath.Dir(path)
+			continue
+		}
+		parent := filepath.Dir(path)
+		if parent == path {
+			return ""
+		}
+		path = parent
+	}
+}
+
+func (a *App) ListWorkspaces() []WorkspaceMeta {
+	migrateLegacyWorkspacesIntoProjects()
+	activeRoot := ""
+	cur, _ := os.Getwd()
+	a.mu.RLock()
+	if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
+		activeRoot = normalizeProjectRoot(tab.WorkspaceRoot)
+	}
+	a.mu.RUnlock()
+	if activeRoot == "" {
+		activeRoot = normalizeProjectRoot(cur)
+	}
+	projects := loadProjectsFile().Projects
+	out := make([]WorkspaceMeta, 0, len(projects))
+	for _, project := range projects {
+		out = append(out, WorkspaceMeta{
+			Path:    project.Root,
+			Name:    projectDisplayName(project),
+			Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
+		})
+	}
+	return out
+}
+
+func (a *App) RemoveWorkspace(dir string) error {
+	if dir == "" {
+		return fmt.Errorf("workspace path is required")
+	}
+	dir = normalizeProjectRoot(dir)
+
+	var fallback *WorkspaceTab
+	// sessionRemovalMu covers every step that can still touch this workspace's
+	// session files: snapshotting, unlinking the tab/runtime bindings, and
+	// closing the unlinked runtimes (quiescing autosave). Once a runtime is
+	// unlinked from a.tabs/detachedSessions it is invisible to
+	// DeleteSession/TrashTopic/RestoreSession, so it must stop writing before
+	// the lock is released. Project bookkeeping, the fallback controller build,
+	// and notifications run after release.
+	if err := func() error {
+		a.sessionRemovalMu.Lock()
+		defer a.sessionRemovalMu.Unlock()
+
+		type workspaceTabCandidate struct {
+			id  string
+			tab *WorkspaceTab
+		}
+
+		var closeTabs []*WorkspaceTab
+		var closeDetached []*WorkspaceTab
+		a.mu.Lock()
+		for _, tab := range a.tabs {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		for _, tab := range a.detachedSessions {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		candidates := make([]workspaceTabCandidate, 0)
+		for id, tab := range a.tabs {
+			if !tabInWorkspace(tab, dir) {
+				continue
+			}
+			candidates = append(candidates, workspaceTabCandidate{id: id, tab: tab})
+		}
+		a.mu.Unlock()
+
+		snapshotted := make(map[string]*WorkspaceTab, len(candidates))
+		for _, candidate := range candidates {
+			id, tab := candidate.id, candidate.tab
+			snapshotted[id] = tab
+			if err := a.snapshotTab(tab); err != nil {
+				slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
+				return fmt.Errorf("save current session before removing workspace: %w", err)
+			}
+		}
+
+		a.mu.Lock()
+		for _, tab := range a.tabs {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		for _, tab := range a.detachedSessions {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		for id, tab := range a.tabs {
+			if tabInWorkspace(tab, dir) && snapshotted[id] != tab {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace tabs changed while removing; retry")
+			}
+		}
+		for _, candidate := range candidates {
+			id, tab := candidate.id, candidate.tab
+			if tab == nil || a.tabs[id] != tab || !tabInWorkspace(tab, dir) {
+				continue
+			}
+			a.markTabRemovedLocked(tab)
+			closeTabs = append(closeTabs, tab)
+			delete(a.tabs, id)
+			a.removeTabOrderLocked(id)
+			if a.activeTabID == id {
+				a.activeTabID = ""
+			}
+		}
+		for key, tab := range a.detachedSessions {
+			if !tabInWorkspace(tab, dir) {
+				continue
+			}
+			closeDetached = append(closeDetached, tab)
+			delete(a.detachedSessions, key)
+		}
+		if len(a.tabs) == 0 {
+			fallback = a.createTabEntry("global", globalTabWorkspaceRoot(), "")
+			fallback.TopicTitle = "Global"
+			fallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}
+			a.tabs[fallback.ID] = fallback
+			a.tabOrder = append(a.tabOrder, fallback.ID)
+			a.activeTabID = fallback.ID
+		} else if a.activeTabID == "" {
+			if ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {
+				a.activeTabID = ordered[0]
+			}
+		}
+		a.saveTabsLocked()
+		a.mu.Unlock()
+
+		for _, tab := range closeTabs {
+			a.closeTabRuntime(tab)
+		}
+		for _, tab := range closeDetached {
+			a.closeTabRuntime(tab)
+		}
+		return nil
+	}(); err != nil {
+		return err
+	}
+
+	// The fallback tab is already linked into a.tabs; its controller build is
+	// asynchronous and does not touch removed session files, so it does not
+	// need the removal lock.
+	if fallback != nil {
+		a.startTabControllerBuild(fallback)
+	}
+
+	forgetWorkspace(dir)
+	if err := removeProject(dir); err != nil {
+		return err
+	}
+	// If the removed workspace was the active one, clear the pointer
+	// so we don't leave a stale reference to a deleted project.
+	if loadWorkspace() == dir {
+		if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
+			// Fall back to the first remaining project
+			saveWorkspace(remaining.Projects[0].Root)
+		} else {
+			// No projects left; clear the active pointer entirely
+			clearWorkspace()
+		}
+	}
+	a.emitProjectTreeChanged()
+	return nil
+}
+
+func migrateLegacyWorkspacesIntoProjects() {
+	legacy := loadWorkspaces()
+	if len(legacy) == 0 {
+		return
+	}
+	_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
+		seen := make(map[string]bool, len(f.Projects)+len(legacy))
+		for _, p := range f.Projects {
+			seen[p.Root] = true
+		}
+		changed := false
+		for _, path := range legacy {
+			root := normalizeProjectRoot(path)
+			if root == "" || seen[root] {
+				continue
+			}
+			f.Projects = append(f.Projects, desktopProject{Root: root})
+			seen[root] = true
+			changed = true
+		}
+		return changed, nil
+	})
+}
+
+func workspaceName(path string) string {
+	name := filepath.Base(path)
+	if name == "." || name == string(filepath.Separator) || name == "" {
+		return path
+	}
+	return name
+}
+
+// tabWorkspaceNameForScope resolves the display name for a tab's workspace.
+// Callers pass tab.Scope copied under a.mu instead of re-reading the tab.
+func tabWorkspaceNameForScope(scope, cwd string) string {
+	if scope == "global" {
+		return globalProjectTitle()
+	}
+	return workspaceName(cwd)
+}
+
+func (a *App) SwitchWorkspace(dir string) (string, error) {
+	if dir == "" {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return "", err
+		}
+		dir = home
+	}
+	if abs, err := filepath.Abs(dir); err == nil {
+		dir = abs
+	}
+	info, err := os.Stat(dir)
+	if err != nil {
+		return "", err
+	}
+	if !info.IsDir() {
+		return "", fmt.Errorf("%s is not a directory", dir)
+	}
+	saveWorkspace(dir)
+
+	// Open a registered topic so the new workspace appears in the project tree
+	// immediately instead of only existing as an in-memory tab.
+	topic, err := a.CreateTopic("project", dir, "")
+	if err != nil {
+		return "", err
+	}
+	var meta TabMeta
+	if a.singleSurfaceLayoutEnabled() {
+		meta, err = a.ActivateTopic("project", dir, topic.ID, "")
+	} else {
+		meta, err = a.OpenProjectTab(dir, topic.ID)
+	}
+	if err != nil {
+		return "", err
+	}
+	return meta.WorkspaceRoot, nil
+}
+
+func (a *App) singleSurfaceLayoutEnabled() bool {
+	cfg, _, err := a.loadDesktopUserConfigForView()
+	if err != nil {
+		return true
+	}
+	return singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())
+}
+
+// HistoryMessage is one prior turn, for the frontend to repopulate its transcript
+// after a reload.
+type HistoryMessage struct {
+	Role               string                    `json:"role"`
+	Content            string                    `json:"content"`
+	Detail             string                    `json:"detail,omitempty"`
+	Code               string                    `json:"code,omitempty"`
+	SubmitText         string                    `json:"submitText,omitempty"`
+	CheckpointTurn     *int                      `json:"checkpointTurn,omitempty"`
+	Reasoning          string                    `json:"reasoning,omitempty"`
+	MemoryCitations    []provider.MemoryCitation `json:"memoryCitations,omitempty"`
+	WorkDurationMs     int64                     `json:"workDurationMs,omitempty"`
+	Level              string                    `json:"level,omitempty"`
+	ToolCalls          []HistoryToolCall         `json:"toolCalls,omitempty"`
+	ToolCallID         string                    `json:"toolCallId,omitempty"`
+	ToolName           string                    `json:"toolName,omitempty"`
+	ToolResultArchived bool                      `json:"toolResultArchived,omitempty"`
+	ToolResultError    string                    `json:"toolResultError,omitempty"`
+	Pending            bool                      `json:"pending,omitempty"`
+	Trigger            string                    `json:"trigger,omitempty"`
+	Messages           int                       `json:"messages,omitempty"`
+	Summary            string                    `json:"summary,omitempty"`
+	Archive            string                    `json:"archive,omitempty"`
+}
+
+type HistoryToolCall struct {
+	ID                string `json:"id"`
+	Name              string `json:"name"`
+	Arguments         string `json:"arguments"`
+	Subject           string `json:"subject,omitempty"`
+	Summary           string `json:"summary,omitempty"`
+	Diff              string `json:"diff,omitempty"`
+	Added             int    `json:"added,omitempty"`
+	Removed           int    `json:"removed,omitempty"`
+	ArgumentsArchived bool   `json:"argumentsArchived,omitempty"`
+}
+
+const (
+	defaultHistoryPageTurns = 60
+	maxHistoryPageTurns     = 200
+)
+
+type HistoryPage struct {
+	Messages   []HistoryMessage `json:"messages"`
+	StartTurn  int              `json:"startTurn"`
+	EndTurn    int              `json:"endTurn"`
+	TotalTurns int              `json:"totalTurns"`
+	HasOlder   bool             `json:"hasOlder"`
+}
+
+// History returns the session's message log.
+func (a *App) History() []HistoryMessage {
+	return a.HistoryForTab("")
+}
+
+func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage {
+	return a.HistoryPageForTab("", beforeTurn, limit)
+}
+
+func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	var sessionDir, sessionPath string
+	if tab != nil {
+		ctrl = tab.Ctrl
+		sessionDir = tabSessionDir(tab)
+		sessionPath = tab.currentSessionPath()
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		if strings.TrimSpace(sessionPath) == "" {
+			return HistoryPage{Messages: []HistoryMessage{}}
+		}
+		page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit)
+		if err != nil {
+			return HistoryPage{Messages: []HistoryMessage{}}
+		}
+		return page
+	}
+	msgs := ctrl.History()
+	dir := controllerSessionDir(ctrl)
+	path := ctrl.SessionPath()
+	return historyPageFromProviderMessages(
+		msgs,
+		sessionDisplayResolver(dir, path),
+		sessionPlannerDisplayTurns(dir, path),
+		ctrl.CheckpointTurnsByMessageIndex(),
+		beforeTurn,
+		limit,
+	)
+}
+
+func normalizeHistoryPageLimit(limit int) int {
+	if limit <= 0 {
+		return defaultHistoryPageTurns
+	}
+	if limit > maxHistoryPageTurns {
+		return maxHistoryPageTurns
+	}
+	return limit
+}
+
+func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage {
+	limit = normalizeHistoryPageLimit(limit)
+	totalTurns := 0
+	for _, msg := range messages {
+		if msg.Role == "user" {
+			totalTurns++
+		}
+	}
+	if beforeTurn <= 0 || beforeTurn > totalTurns {
+		beforeTurn = totalTurns
+	}
+	startTurn := beforeTurn - limit
+	if startTurn < 0 {
+		startTurn = 0
+	}
+	page := HistoryPage{
+		StartTurn:  startTurn,
+		EndTurn:    beforeTurn,
+		TotalTurns: totalTurns,
+		HasOlder:   startTurn > 0,
+	}
+	if len(messages) == 0 || startTurn >= beforeTurn {
+		page.Messages = []HistoryMessage{}
+		return page
+	}
+	page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn)
+	return page
+}
+
+func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage {
+	out := make([]HistoryMessage, 0, len(messages))
+	turn := -1
+	for _, msg := range messages {
+		if msg.Role == "user" {
+			turn++
+		}
+		if turn < 0 {
+			if startTurn == 0 {
+				out = append(out, msg)
+			}
+			continue
+		}
+		if turn >= startTurn && turn < endTurn {
+			out = append(out, msg)
+		}
+	}
+	return out
+}
+
+func (a *App) HistoryForTab(tabID string) []HistoryMessage {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	var sessionDir, sessionPath string
+	if tab != nil {
+		ctrl = tab.Ctrl
+		sessionDir = tabSessionDir(tab)
+		sessionPath = tab.currentSessionPath()
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		if strings.TrimSpace(sessionPath) == "" {
+			return []HistoryMessage{}
+		}
+		messages, err := previewSessionMessages(sessionDir, sessionPath)
+		if err != nil {
+			return []HistoryMessage{}
+		}
+		return messages
+	}
+	msgs := ctrl.History()
+	dir := controllerSessionDir(ctrl)
+	path := ctrl.SessionPath()
+	return historyMessagesWithPlannerDisplays(
+		msgs,
+		sessionDisplayResolver(dir, path),
+		sessionPlannerDisplayTurns(dir, path),
+		ctrl.CheckpointTurnsByMessageIndex(),
+	)
+}
+
+func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	if tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return []int{}
+	}
+	return historyCheckpointTurns(
+		ctrl.History(),
+		sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()),
+		ctrl.CheckpointTurnsByMessageIndex(),
+	)
+}
+
+func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int {
+	out := make([]int, 0)
+	for index, msg := range msgs {
+		if msg.Role != provider.RoleUser {
+			continue
+		}
+		if _, isSteer := agent.SteerText(msg.Content); isSteer {
+			continue
+		}
+		if control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) {
+			continue
+		}
+		turn, ok := checkpointTurns[index]
+		if !ok {
+			turn = -1
+		}
+		out = append(out, turn)
+	}
+	return out
+}
+
+func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {
+	return historyMessagesWithPlannerDisplays(msgs, resolveUserContent, nil, nil)
+}
+
+func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage {
+	replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)
+	toolResults := historyToolResultsByID(msgs)
+	return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, replayedTodoArgs, toolResults)
+}
+
+func historyMessagesWithPlannerDisplaysAndLookups(
+	msgs []provider.Message,
+	resolveUserContent func(string) string,
+	plannerTurns []plannerDisplayTurn,
+	checkpointTurns map[int]int,
+	replayedTodoArgs map[string]string,
+	toolResults map[string]provider.Message,
+) []HistoryMessage {
+	out := make([]HistoryMessage, 0, len(msgs))
+	plannerByUserHash := plannerTurnsByUserHash(plannerTurns)
+	for index, m := range msgs {
+		content := m.Content
+		var checkpointTurn *int
+		if m.Role == provider.RoleUser {
+			// Mid-turn steer messages are persisted in the session so they
+			// survive tab switches. They are surfaced as a notice (↪ text)
+			// — matching the live Steer event look — rather than as a
+			// regular user bubble or being filtered as synthetic (#4044).
+			// Check against the raw m.Content: resolveUserContent applies
+			// StripComposePrefixes which trims trailing whitespace.
+			if steerText, isSteer := agent.SteerText(m.Content); isSteer {
+				out = append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText})
+				continue
+			}
+			content = resolveUserContent(m.Content)
+			if control.IsSyntheticUserMessage(content) {
+				continue
+			}
+			if turn, ok := checkpointTurns[index]; ok {
+				turnCopy := turn
+				checkpointTurn = &turnCopy
+			}
+		}
+		reasoning := ""
+		if m.Role == provider.RoleAssistant {
+			reasoning = m.ReasoningContent
+		}
+		hm := HistoryMessage{Role: string(m.Role), Content: content, CheckpointTurn: checkpointTurn, Reasoning: reasoning, WorkDurationMs: m.WorkDurationMs}
+		if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
+			hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
+		}
+		if m.Role == provider.RoleUser && content != m.Content {
+			if agent.ContainsMemoryCompilerExecution(m.Content) {
+				// Never expose the compiler contract itself. A safely unwrapped
+				// slash invocation is useful display metadata, though: it lets the
+				// frontend restore the selected skill/subagent in history and trash.
+				if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
+					hm.SubmitText = replay
+				}
+			} else {
+				hm.SubmitText = m.Content
+			}
+		}
+		if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
+			hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
+			for i, tc := range m.ToolCalls {
+				args := tc.Arguments
+				if tc.Name == "todo_write" {
+					if replayed, ok := replayedTodoArgs[tc.ID]; ok {
+						args = replayed
+					}
+				}
+				hm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])
+			}
+		}
+		if m.Role == provider.RoleTool {
+			hm.ToolCallID = m.ToolCallID
+			hm.ToolName = m.Name
+			hm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != "")
+		}
+		out = append(out, hm)
+		if m.Role == provider.RoleUser {
+			if turns := plannerByUserHash[messageDisplayKey(m.Content)]; len(turns) > 0 {
+				out = append(out, cloneHistoryMessages(turns[0].Messages)...)
+				plannerByUserHash[messageDisplayKey(m.Content)] = turns[1:]
+			}
+		}
+	}
+	return out
+}
+
+func historyPageFromProviderMessages(
+	msgs []provider.Message,
+	resolveUserContent func(string) string,
+	plannerTurns []plannerDisplayTurn,
+	checkpointTurns map[int]int,
+	beforeTurn, limit int,
+) HistoryPage {
+	limit = normalizeHistoryPageLimit(limit)
+	totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent)
+	if beforeTurn <= 0 || beforeTurn > totalTurns {
+		beforeTurn = totalTurns
+	}
+	startTurn := beforeTurn - limit
+	if startTurn < 0 {
+		startTurn = 0
+	}
+	page := HistoryPage{
+		StartTurn:  startTurn,
+		EndTurn:    beforeTurn,
+		TotalTurns: totalTurns,
+		HasOlder:   startTurn > 0,
+	}
+	if len(msgs) == 0 || startTurn >= beforeTurn {
+		page.Messages = []HistoryMessage{}
+		return page
+	}
+	pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn)
+	page.Messages = historyMessagesWithPlannerDisplaysAndLookups(
+		pageMessages,
+		resolveUserContent,
+		plannerTurns,
+		checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes),
+		historyTodoArgsWithCompleteSteps(msgs),
+		historyToolResultsByID(msgs),
+	)
+	return page
+}
+
+func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int {
+	total := 0
+	for _, msg := range msgs {
+		if isVisibleHistoryUser(msg, resolveUserContent) {
+			total++
+		}
+	}
+	return total
+}
+
+func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool {
+	if msg.Role != provider.RoleUser {
+		return false
+	}
+	if _, isSteer := agent.SteerText(msg.Content); isSteer {
+		return false
+	}
+	return !control.IsSyntheticUserMessage(resolveUserContent(msg.Content))
+}
+
+func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) {
+	out := make([]provider.Message, 0, len(msgs))
+	indexes := make([]int, 0, len(msgs))
+	turn := -1
+	for index, msg := range msgs {
+		if isVisibleHistoryUser(msg, resolveUserContent) {
+			turn++
+		}
+		if turn < 0 {
+			if startTurn == 0 {
+				out = append(out, msg)
+				indexes = append(indexes, index)
+			}
+			continue
+		}
+		if turn >= startTurn && turn < endTurn {
+			out = append(out, msg)
+			indexes = append(indexes, index)
+		}
+	}
+	return out, indexes
+}
+
+func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int {
+	if len(checkpointTurns) == 0 || len(originalIndexes) == 0 {
+		return nil
+	}
+	out := map[int]int{}
+	for pageIndex, originalIndex := range originalIndexes {
+		if turn, ok := checkpointTurns[originalIndex]; ok {
+			out[pageIndex] = turn
+		}
+	}
+	return out
+}
+
+func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn {
+	out := map[string][]plannerDisplayTurn{}
+	for _, turn := range turns {
+		if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
+			continue
+		}
+		out[turn.UserHash] = append(out[turn.UserHash], turn)
+	}
+	return out
+}
+
+func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage {
+	if len(in) == 0 {
+		return nil
+	}
+	out := make([]HistoryMessage, len(in))
+	copy(out, in)
+	for i := range out {
+		if len(in[i].MemoryCitations) > 0 {
+			out[i].MemoryCitations = append([]provider.MemoryCitation(nil), in[i].MemoryCitations...)
+		}
+		if len(in[i].ToolCalls) > 0 {
+			out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...)
+		}
+	}
+	return out
+}
+
+const historyToolPreviewLimit = 2_000
+
+func historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {
+	call := HistoryToolCall{
+		ID:      tc.ID,
+		Name:    tc.Name,
+		Subject: historyToolSubject(tc.Name, args),
+		Summary: historyToolSummary(tc.Name, args, result.Content),
+		Diff:    tc.Diff,
+		Added:   tc.Added,
+		Removed: tc.Removed,
+	}
+	if tc.Name == "todo_write" {
+		call.Arguments = args
+		return call
+	}
+	if tc.ID == "" {
+		call.Arguments = args
+		return call
+	}
+	if args != "" {
+		call.ArgumentsArchived = true
+	}
+	return call
+}
+
+func historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {
+	out := map[string]provider.Message{}
+	for _, msg := range msgs {
+		if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
+			continue
+		}
+		out[msg.ToolCallID] = msg
+	}
+	return out
+}
+
+func historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {
+	if content == "" {
+		return "", false, ""
+	}
+	if !canArchive {
+		if historyToolResultFailed(content) {
+			return content, false, content
+		}
+		return content, false, ""
+	}
+	if historyToolResultFailed(content) {
+		display = clipHistoryToolPreview(strings.TrimSpace(content))
+		return display, display != content, display
+	}
+	return "", true, ""
+}
+
+func clipHistoryToolPreview(s string) string {
+	if len(s) <= historyToolPreviewLimit {
+		return s
+	}
+	return strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + "\n..."
+}
+
+func historyToolSubject(name, args string) string {
+	a := parseHistoryToolArgs(args)
+	var subject string
+	switch name {
+	case "bash":
+		subject = historyArgString(a, "command")
+	case "grep", "glob":
+		subject = firstNonEmpty(historyArgString(a, "pattern"), historyArgString(a, "path"))
+	case "web_fetch":
+		subject = historyArgString(a, "url")
+	case "task":
+		subject = firstNonEmpty(historyArgString(a, "description"), historyArgString(a, "prompt"))
+	case "run_skill":
+		subject = historyArgString(a, "name")
+	case "move_file":
+		src := historyArgString(a, "source_path")
+		dst := historyArgString(a, "destination_path")
+		if src != "" && dst != "" {
+			subject = src + " -> " + dst
+		} else {
+			subject = firstNonEmpty(src, dst)
+		}
+	case "remember":
+		subject = firstNonEmpty(historyArgString(a, "name"), historyArgString(a, "description"))
+	case "todo_write", "exit_plan_mode":
+		subject = ""
+	default:
+		subject = firstNonEmpty(historyArgString(a, "path"), historyArgString(a, "file_path"))
+	}
+	return clipSingleLine(subject, 240)
+}
+
+func historyToolSummary(name, args, output string) string {
+	if historyToolResultFailed(output) {
+		return ""
+	}
+	a := parseHistoryToolArgs(args)
+	switch name {
+	case "write_file":
+		if content := historyArgString(a, "content"); content != "" {
+			return fmt.Sprintf("%d lines", historyLineCount(content))
+		}
+	case "edit_file":
+		oldText := historyArgString(a, "old_string")
+		newText := historyArgString(a, "new_string")
+		if oldText != "" || newText != "" {
+			return fmt.Sprintf("%d -> %d lines", historyLineCount(oldText), historyLineCount(newText))
+		}
+	case "multi_edit":
+		if edits, ok := a["edits"].([]any); ok && len(edits) > 0 {
+			return fmt.Sprintf("%d edits", len(edits))
+		}
+	}
+	if output == "" {
+		return ""
+	}
+	switch name {
+	case "read_file":
+		if strings.HasPrefix(output, "(empty file)") {
+			return "empty file"
+		}
+		if arrows := strings.Count(output, "→"); arrows > 0 {
+			return fmt.Sprintf("%d lines", arrows)
+		}
+		return fmt.Sprintf("%d lines", historyLineCount(output))
+	case "grep":
+		return fmt.Sprintf("%d matches", historyNonEmptyLineCount(output))
+	case "glob":
+		return fmt.Sprintf("%d files", historyNonEmptyLineCount(output))
+	case "ls":
+		return fmt.Sprintf("%d entries", historyNonEmptyLineCount(output))
+	case "web_fetch":
+		return clipSingleLine(strings.SplitN(output, "\n", 2)[0], 80)
+	case "bash":
+		if strings.TrimSpace(output) == "" {
+			return "no output"
+		}
+		return fmt.Sprintf("%d lines", historyLineCount(output))
+	default:
+		return ""
+	}
+}
+
+func parseHistoryToolArgs(args string) map[string]any {
+	if args == "" {
+		return map[string]any{}
+	}
+	var out map[string]any
+	if err := json.Unmarshal([]byte(args), &out); err != nil {
+		return map[string]any{}
+	}
+	return out
+}
+
+func historyArgString(args map[string]any, key string) string {
+	if v, ok := args[key].(string); ok {
+		return v
+	}
+	return ""
+}
+
+func historyLineCount(s string) int {
+	if s == "" {
+		return 0
+	}
+	s = strings.TrimSuffix(s, "\n")
+	if s == "" {
+		return 0
+	}
+	return strings.Count(s, "\n") + 1
+}
+
+func historyNonEmptyLineCount(s string) int {
+	count := 0
+	for _, line := range strings.Split(s, "\n") {
+		if strings.TrimSpace(line) != "" {
+			count++
+		}
+	}
+	return count
+}
+
+func clipSingleLine(s string, max int) string {
+	s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return clipStringBytes(s, max)
+	}
+	return clipStringBytes(s, max-3) + "..."
+}
+
+func clipStringBytes(s string, max int) string {
+	if max <= 0 {
+		return ""
+	}
+	if len(s) <= max {
+		return s
+	}
+	for max > 0 && !utf8.RuneStart(s[max]) {
+		max--
+	}
+	return s[:max]
+}
+
+func historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {
+	successful := successfulHistoryToolCallIDs(msgs)
+	out := map[string]string{}
+	var todos []evidence.TodoItem
+	latestTodoID := ""
+	for _, m := range msgs {
+		for _, tc := range m.ToolCalls {
+			if tc.ID == "" || !successful[tc.ID] {
+				continue
+			}
+			switch tc.Name {
+			case "todo_write":
+				rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
+				if len(rec.Todos) == 0 {
+					continue
+				}
+				todos = append([]evidence.TodoItem(nil), rec.Todos...)
+				latestTodoID = tc.ID
+				if args, ok := todoArgsJSON(todos); ok {
+					out[latestTodoID] = args
+				}
+			case "complete_step":
+				if latestTodoID == "" || len(todos) == 0 {
+					continue
+				}
+				rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
+				match, ok := evidence.MatchStep(rec.Step, todos)
+				if !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == "completed" {
+					continue
+				}
+				todos[match.Index-1].Status = "completed"
+				promoteNextHistoryTodo(todos)
+				if args, ok := todoArgsJSON(todos); ok {
+					out[latestTodoID] = args
+				}
+			}
+		}
+	}
+	return out
+}
+
+func successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {
+	successful := map[string]bool{}
+	for _, msg := range msgs {
+		if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
+			continue
+		}
+		if !historyToolResultFailed(msg.Content) {
+			successful[msg.ToolCallID] = true
+		}
+	}
+	return successful
+}
+
+func historyToolResultFailed(content string) bool {
+	content = strings.TrimSpace(content)
+	return strings.HasPrefix(content, "error:") ||
+		strings.HasPrefix(content, "blocked:") ||
+		strings.HasPrefix(content, "Error:") ||
+		strings.HasPrefix(content, "[error")
+}
+
+func todoArgsJSON(todos []evidence.TodoItem) (string, bool) {
+	b, err := json.Marshal(map[string]any{"todos": todos})
+	if err != nil {
+		return "", false
+	}
+	return string(b), true
+}
+
+func promoteNextHistoryTodo(todos []evidence.TodoItem) {
+	for _, todo := range todos {
+		if todoStatusForHistory(todo.Status) == "in_progress" {
+			return
+		}
+	}
+	for i := range todos {
+		if todoStatusForHistory(todos[i].Status) == "pending" {
+			todos[i].Status = "in_progress"
+			return
+		}
+	}
+}
+
+func todoStatusForHistory(status string) string {
+	status = strings.TrimSpace(status)
+	if status == "" {
+		return "pending"
+	}
+	return status
+}
+
+func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {
+	sessionPath, _, err := validateSessionPath(sessionDir, path)
+	if err != nil {
+		return nil, err
+	}
+	if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
+		return out, err
+	}
+	loaded, err := agent.LoadSession(sessionPath)
+	if err != nil {
+		return nil, err
+	}
+	return historyMessagesWithPlannerDisplays(
+		loaded.Snapshot(),
+		sessionDisplayResolver(sessionDir, sessionPath),
+		sessionPlannerDisplayTurns(sessionDir, sessionPath),
+		nil,
+	), nil
+}
+
+func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) {
+	sessionPath, _, err := validateSessionPath(sessionDir, path)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
+		if err != nil {
+			return HistoryPage{}, err
+		}
+		return historyPageFromMessages(out, beforeTurn, limit), nil
+	}
+	loaded, err := agent.LoadSession(sessionPath)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	return historyPageFromProviderMessages(
+		loaded.Snapshot(),
+		sessionDisplayResolver(sessionDir, sessionPath),
+		sessionPlannerDisplayTurns(sessionDir, sessionPath),
+		nil,
+		beforeTurn,
+		limit,
+	), nil
+}
+
+type previewEventRecord struct {
+	Kind             string                    `json:"kind"`
+	Type             string                    `json:"type"`
+	Role             string                    `json:"role"`
+	Time             json.RawMessage           `json:"time"`
+	Timestamp        json.RawMessage           `json:"timestamp"`
+	CreatedAt        json.RawMessage           `json:"createdAt"`
+	CreatedAtSnake   json.RawMessage           `json:"created_at"`
+	UpdatedAt        json.RawMessage           `json:"updatedAt"`
+	UpdatedAtSnake   json.RawMessage           `json:"updated_at"`
+	Text             string                    `json:"text"`
+	Detail           string                    `json:"detail"`
+	Code             string                    `json:"code"`
+	Content          string                    `json:"content"`
+	Reasoning        string                    `json:"reasoning"`
+	ReasoningContent string                    `json:"reasoningContent"`
+	MemoryCitations  []provider.MemoryCitation `json:"memoryCitations"`
+	Level            string                    `json:"level"`
+	ToolCalls        []previewToolCall         `json:"toolCalls"`
+	CallID           string                    `json:"callId"`
+	ToolCallID       string                    `json:"toolCallId"`
+	ToolName         string                    `json:"toolName"`
+	Name             string                    `json:"name"`
+	Output           string                    `json:"output"`
+	Compaction       *previewCompaction        `json:"compaction"`
+	Trigger          string                    `json:"trigger"`
+	Messages         int                       `json:"messages"`
+	Summary          string                    `json:"summary"`
+	Archive          string                    `json:"archive"`
+}
+
+type previewToolCall struct {
+	ID        string `json:"id"`
+	Name      string `json:"name"`
+	Arguments string `json:"arguments"`
+	Function  struct {
+		Name      string `json:"name"`
+		Arguments string `json:"arguments"`
+	} `json:"function"`
+}
+
+type previewCompaction struct {
+	Trigger  string `json:"trigger"`
+	Messages int    `json:"messages"`
+	Summary  string `json:"summary"`
+	Archive  string `json:"archive"`
+}
+
+func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, false, err
+	}
+	defer f.Close()
+
+	dec := json.NewDecoder(f)
+	out := []HistoryMessage{}
+	toolName := map[string]string{}
+	sawEvent := false
+	for {
+		var rec previewEventRecord
+		if err := dec.Decode(&rec); err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			if sawEvent {
+				return out, true, nil
+			}
+			return nil, false, nil
+		}
+		eventName := strings.TrimSpace(rec.Kind)
+		if eventName == "" {
+			eventName = strings.TrimSpace(rec.Type)
+		}
+		if eventName == "" {
+			continue
+		}
+		sawEvent = true
+		switch eventName {
+		case "user.message":
+			if rec.Text != "" {
+				out = append(out, HistoryMessage{Role: "user", Content: rec.Text})
+			}
+		case "model.final":
+			hm := HistoryMessage{Role: "assistant", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}
+			if len(rec.MemoryCitations) > 0 {
+				hm.MemoryCitations = append([]provider.MemoryCitation(nil), rec.MemoryCitations...)
+			}
+			for _, tc := range rec.ToolCalls {
+				id := tc.ID
+				name := firstNonEmpty(tc.Name, tc.Function.Name)
+				args := firstNonEmpty(tc.Arguments, tc.Function.Arguments)
+				hm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))
+				if id != "" {
+					toolName[id] = name
+				}
+			}
+			out = append(out, hm)
+		case "tool.result":
+			callID := firstNonEmpty(rec.CallID, rec.ToolCallID)
+			content := firstNonEmpty(rec.Output, rec.Content)
+			display, archived, errPreview := historyToolResultContent(content, callID != "")
+			if len(out) > 0 && callID != "" {
+				updateHistoryToolCallSummary(out, callID, content)
+			}
+			out = append(out, HistoryMessage{
+				Role:               "tool",
+				ToolCallID:         callID,
+				ToolName:           firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),
+				Content:            display,
+				ToolResultArchived: archived,
+				ToolResultError:    errPreview,
+			})
+		case "phase":
+			out = append(out, HistoryMessage{Role: "phase", Content: firstNonEmpty(rec.Text, rec.Content)})
+		case "notice":
+			level := rec.Level
+			if level != "warn" {
+				level = "info"
+			}
+			out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail, Code: rec.Code})
+		case "compaction_started":
+			c := rec.compactionPayload()
+			out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
+		case "compaction_done":
+			c := rec.compactionPayload()
+			out = append(out, HistoryMessage{
+				Role:     "compaction",
+				Trigger:  c.Trigger,
+				Messages: c.Messages,
+				Summary:  c.Summary,
+				Archive:  c.Archive,
+			})
+		}
+	}
+	return out, sawEvent, nil
+}
+
+func (r previewEventRecord) compactionPayload() previewCompaction {
+	if r.Compaction != nil {
+		return *r.Compaction
+	}
+	return previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}
+}
+
+func updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {
+	if callID == "" {
+		return
+	}
+	for i := len(out) - 1; i >= 0; i-- {
+		for j := range out[i].ToolCalls {
+			call := &out[i].ToolCalls[j]
+			if call.ID != callID {
+				continue
+			}
+			if call.Summary == "" {
+				call.Summary = historyToolSummary(call.Name, call.Arguments, output)
+			}
+			return
+		}
+	}
+}
+
+func firstNonEmpty(values ...string) string {
+	for _, value := range values {
+		if value != "" {
+			return value
+		}
+	}
+	return ""
+}
+
+// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
+// and Window both zero means no context-window data yet.
+type ContextInfo struct {
+	Used            int                         `json:"used"`
+	Window          int                         `json:"window"`
+	SessionTokens   int                         `json:"sessionTokens"`
+	CompactRatio    float64                     `json:"compactRatio,omitempty"`
+	SessionCost     float64                     `json:"sessionCost,omitempty"`
+	SessionCurrency string                      `json:"sessionCurrency,omitempty"`
+	CacheHitTokens  int                         `json:"cacheHitTokens,omitempty"`
+	CacheMissTokens int                         `json:"cacheMissTokens,omitempty"`
+	Sources         map[string]usageSourceStats `json:"sources,omitempty"`
+}
+
+// ContextUsage returns the latest context-window gauge numbers.
+func (a *App) ContextUsage() ContextInfo {
+	return a.ContextUsageForTab("")
+}
+
+func (a *App) ContextUsageForTab(tabID string) ContextInfo {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	if tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+
+	var info ContextInfo
+	if tab != nil {
+		// Re-key first: a controller-side rotation (typed /new) may have
+		// swapped sessions without the App noticing, and the stale totals
+		// would otherwise be reported — and then persisted — under the new
+		// session (#5850).
+		if ctrl != nil {
+			if sp := ctrl.SessionPath(); sp != "" {
+				tab.syncTelemetryToSession(sp)
+			}
+		}
+		snap := tab.telemetrySnapshot()
+		info.SessionTokens = snap.Usage.TotalTokens
+		info.SessionCost = snap.Usage.SessionCost
+		info.SessionCurrency = snap.Usage.SessionCurrency
+		info.CacheHitTokens = snap.Usage.CacheHitTokens
+		info.CacheMissTokens = snap.Usage.CacheMissTokens
+		info.Sources = snap.Usage.Sources
+	}
+	if ctrl == nil {
+		return info
+	}
+	used, window := ctrl.ContextSnapshot()
+	info.Used = used
+	info.Window = window
+	info.CompactRatio = ctrl.CompactRatio()
+	return info
+}
+
+// BalanceInfo is the wallet-balance readout for the status bar. Available is true
+// only when a balance was fetched; Display is the formatted amount (e.g. "¥110.00")
+// and is "" when the active provider declares no balance_url — the frontend then
+// omits the readout. Err carries a fetch failure for an optional tooltip.
+type BalanceInfo struct {
+	Available bool   `json:"available"`
+	Display   string `json:"display"`
+	Err       string `json:"err,omitempty"`
+}
+
+// Balance queries the active provider's wallet balance (a network call). It
+// returns an empty (unavailable) readout when no provider balance_url is set, the
+// controller is down, or the fetch fails — so the status bar simply shows nothing
+// rather than an error.
+func (a *App) Balance() BalanceInfo {
+	return a.BalanceForTab("")
+}
+
+func (a *App) BalanceForTab(tabID string) BalanceInfo {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return BalanceInfo{}
+	}
+	b, err := ctrl.Balance(a.ctx)
+	if err != nil {
+		return BalanceInfo{Err: err.Error()}
+	}
+	if b == nil {
+		return BalanceInfo{} // provider declares no balance endpoint
+	}
+	return BalanceInfo{Available: true, Display: b.Display()}
+}
+
+// JobView is one running background job (bash/task started with
+// run_in_background) for the status-bar indicator.
+type JobView struct {
+	ID        string `json:"id"`
+	Kind      string `json:"kind"`
+	Label     string `json:"label"`
+	Status    string `json:"status"`
+	StartedAt int64  `json:"startedAt"`
+}
+
+// Jobs returns the still-running background jobs for the status bar. It refreshes
+// on demand (mount, turn end, and on each notice the frontend receives).
+func (a *App) Jobs() []JobView {
+	out := []JobView{}
+	ctrl := a.ctrlByTabID("")
+	return a.jobsForCtrl(ctrl, out)
+}
+
+func (a *App) JobsForTab(tabID string) []JobView {
+	out := []JobView{}
+	ctrl := a.ctrlByTabID(tabID)
+	return a.jobsForCtrl(ctrl, out)
+}
+
+func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
+	if ctrl == nil {
+		return out
+	}
+	for _, v := range ctrl.Jobs() {
+		out = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})
+	}
+	return out
+}
+
+// Meta describes the session for the frontend's header and status line.
+type Meta struct {
+	Label             string                   `json:"label"`
+	Ready             bool                     `json:"ready"`
+	StartupErr        string                   `json:"startupErr,omitempty"`
+	EventChannel      string                   `json:"eventChannel"`
+	Cwd               string                   `json:"cwd"`
+	WorkspaceRoot     string                   `json:"workspaceRoot,omitempty"`
+	WorkspaceName     string                   `json:"workspaceName,omitempty"`
+	WorkspacePath     string                   `json:"workspacePath,omitempty"`
+	GitBranch         string                   `json:"gitBranch,omitempty"`
+	ImageInputEnabled bool                     `json:"imageInputEnabled"`
+	AutoApproveTools  bool                     `json:"autoApproveTools"`
+	Bypass            bool                     `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
+	CollaborationMode string                   `json:"collaborationMode"`
+	ToolApprovalMode  string                   `json:"toolApprovalMode"`
+	TokenMode         string                   `json:"tokenMode"`
+	Goal              string                   `json:"goal,omitempty"`
+	GoalStatus        string                   `json:"goalStatus,omitempty"`
+	AutoResearch      *AutoResearchCompactView `json:"autoResearch,omitempty"`
+}
+
+type AutoResearchCompactView struct {
+	TaskID        string `json:"taskId"`
+	Status        string `json:"status"`
+	Iteration     int    `json:"iteration"`
+	PivotRequired bool   `json:"pivotRequired"`
+	StaleCount    int    `json:"staleCount"`
+}
+
+type AutoResearchCriterionView struct {
+	ID            string `json:"id"`
+	Description   string `json:"description"`
+	Required      bool   `json:"required"`
+	EvidenceCount int    `json:"evidenceCount"`
+	Status        string `json:"status"`
+}
+
+type AutoResearchStatusView struct {
+	TaskID             string                      `json:"taskId"`
+	Goal               string                      `json:"goal"`
+	Status             string                      `json:"status"`
+	Iteration          int                         `json:"iteration"`
+	CurrentDirection   string                      `json:"currentDirection"`
+	StaleCount         int                         `json:"staleCount"`
+	PivotCount         int                         `json:"pivotCount"`
+	PivotRequired      bool                        `json:"pivotRequired"`
+	LastHeartbeatAt    string                      `json:"lastHeartbeatAt"`
+	FindingCount       int                         `json:"findingCount"`
+	OpenCriteria       []AutoResearchCriterionView `json:"openCriteria"`
+	Blocker            string                      `json:"blocker"`
+	TaskPath           string                      `json:"taskPath"`
+	NextRequiredAction string                      `json:"nextRequiredAction"`
+}
+
+type AutoResearchFindingView struct {
+	ID        string   `json:"id"`
+	Kind      string   `json:"kind"`
+	Summary   string   `json:"summary"`
+	Source    string   `json:"source"`
+	Command   string   `json:"command,omitempty"`
+	Paths     []string `json:"paths,omitempty"`
+	Accepted  bool     `json:"accepted"`
+	CreatedAt string   `json:"createdAt"`
+}
+
+type AutoResearchEvidenceView struct {
+	ID       string   `json:"id"`
+	Kind     string   `json:"kind"`
+	Summary  string   `json:"summary"`
+	Source   string   `json:"source"`
+	Command  string   `json:"command,omitempty"`
+	Paths    []string `json:"paths,omitempty"`
+	Accepted bool     `json:"accepted"`
+}
+
+// Meta reports the model label, readiness, any startup error, the working
+// directory (for the status line), and the runtime event channel the frontend
+// subscribes to.
+func (a *App) Meta() Meta {
+	return a.MetaForTab("")
+}
+
+func (a *App) imageInputEnabledForTab(tabID string) bool {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ref, root string
+	if tab != nil {
+		ref = tab.model
+		root = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	if tab == nil {
+		return false
+	}
+	cfg, err := config.LoadForRoot(root)
+	if err == nil && ref == "" {
+		ref = cfg.DefaultModel
+	}
+	if err != nil || ref == "" {
+		return false
+	}
+	entry, ok := cfg.ResolveModel(ref)
+	return ok && config.EffectiveVision(entry)
+}
+
+func (a *App) MetaForTab(tabID string) Meta {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	snap := snapshotTabRuntimeLocked(tab)
+	a.mu.RUnlock()
+	if tab == nil {
+		return Meta{EventChannel: eventChannel}
+	}
+	cwd := snap.workspaceRoot
+	if cwd == "" {
+		cwd, _ = os.Getwd()
+	}
+	autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
+	collaborationMode := snap.collaborationMode()
+	toolApprovalMode := snap.currentToolApprovalMode()
+	tokenMode := snap.currentTokenMode()
+	goal := snap.currentGoal()
+	goalStatus := snap.currentGoalStatus()
+	return Meta{
+		Label:             snap.label,
+		Ready:             snap.ready,
+		StartupErr:        snap.startupErr,
+		EventChannel:      eventChannel,
+		Cwd:               cwd,
+		WorkspaceRoot:     cwd,
+		WorkspaceName:     tabWorkspaceNameForScope(snap.scope, cwd),
+		WorkspacePath:     cwd,
+		GitBranch:         workspaceGitBranchForMeta(cwd),
+		ImageInputEnabled: a.imageInputEnabledForTab(tabID),
+		AutoApproveTools:  autoApproveTools,
+		Bypass:            autoApproveTools,
+		CollaborationMode: collaborationMode,
+		ToolApprovalMode:  toolApprovalMode,
+		TokenMode:         tokenMode,
+		Goal:              goal,
+		GoalStatus:        goalStatus,
+		AutoResearch:      compactAutoResearchFromController(snap.ctrl),
+	}
+}
+
+func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView {
+	if ctrl == nil {
+		return nil
+	}
+	summary, ok := ctrl.AutoResearchSummary()
+	if !ok || summary == nil || summary.TaskID == "" {
+		return nil
+	}
+	return &AutoResearchCompactView{
+		TaskID:        summary.TaskID,
+		Status:        summary.Status,
+		Iteration:     summary.Iteration,
+		PivotRequired: summary.PivotRequired,
+		StaleCount:    summary.StaleCount,
+	}
+}
+
+func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView {
+	if tab == nil || tab.Ctrl == nil {
+		return nil
+	}
+	summary, ok := tab.Ctrl.AutoResearchSummary()
+	if !ok || summary == nil || summary.TaskID == "" {
+		return nil
+	}
+	return &AutoResearchCompactView{
+		TaskID:        summary.TaskID,
+		Status:        summary.Status,
+		Iteration:     summary.Iteration,
+		PivotRequired: summary.PivotRequired,
+		StaleCount:    summary.StaleCount,
+	}
+}
+
+func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView {
+	if summary == nil {
+		return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
+	}
+	open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria))
+	for _, criterion := range summary.OpenCriteria {
+		open = append(open, AutoResearchCriterionView{
+			ID:            criterion.ID,
+			Description:   criterion.Description,
+			Required:      criterion.Required,
+			EvidenceCount: criterion.EvidenceCount,
+			Status:        criterion.Status,
+		})
+	}
+	return AutoResearchStatusView{
+		TaskID:             summary.TaskID,
+		Goal:               summary.Goal,
+		Status:             summary.Status,
+		Iteration:          summary.Iteration,
+		CurrentDirection:   summary.CurrentDirection,
+		StaleCount:         summary.StaleCount,
+		PivotCount:         summary.PivotCount,
+		PivotRequired:      summary.PivotRequired,
+		LastHeartbeatAt:    summary.LastHeartbeatAt.Format(time.RFC3339),
+		FindingCount:       summary.FindingCount,
+		OpenCriteria:       open,
+		Blocker:            summary.Blocker,
+		TaskPath:           summary.TaskPath,
+		NextRequiredAction: summary.NextRequiredAction,
+	}
+}
+
+func (a *App) AutoResearchCurrent() AutoResearchStatusView {
+	return a.AutoResearchStatus("")
+}
+
+func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
+	}
+	summary, ok := ctrl.AutoResearchSummary()
+	if !ok {
+		return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
+	}
+	return autoResearchStatusView(summary)
+}
+
+func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return []AutoResearchStatusView{}
+	}
+	summaries, ok := ctrl.AutoResearchList()
+	if !ok {
+		return []AutoResearchStatusView{}
+	}
+	out := make([]AutoResearchStatusView, 0, len(summaries))
+	for i := range summaries {
+		out = append(out, autoResearchStatusView(&summaries[i]))
+	}
+	return out
+}
+
+func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return []AutoResearchFindingView{}
+	}
+	findings, ok := ctrl.AutoResearchFindings(limit)
+	if !ok {
+		return []AutoResearchFindingView{}
+	}
+	out := make([]AutoResearchFindingView, 0, len(findings))
+	for _, finding := range findings {
+		out = append(out, AutoResearchFindingView{
+			ID:        finding.ID,
+			Kind:      finding.Kind,
+			Summary:   finding.Summary,
+			Source:    finding.Source,
+			Command:   finding.Command,
+			Paths:     append([]string(nil), finding.Paths...),
+			Accepted:  finding.Accepted,
+			CreatedAt: finding.CreatedAt.Format(time.RFC3339),
+		})
+	}
+	return out
+}
+
+func (a *App) AutoResearchOpenTask(tabID string) error {
+	status := a.AutoResearchStatus(tabID)
+	if strings.TrimSpace(status.TaskPath) == "" {
+		return os.ErrInvalid
+	}
+	return a.RevealPath(status.TaskPath)
+}
+
+func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return os.ErrInvalid
+	}
+	return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{
+		ID:       input.ID,
+		Kind:     input.Kind,
+		Summary:  input.Summary,
+		Source:   input.Source,
+		Command:  input.Command,
+		Paths:    append([]string(nil), input.Paths...),
+		Accepted: input.Accepted,
+	})
+}
+
+func (a *App) SetGoal(goal string) {
+	a.SetGoalForTab("", goal)
+}
+
+func (a *App) SetGoalForTab(tabID, goal string) {
+	goal = strings.TrimSpace(goal)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	tab.goal = goal
+	if goal != "" {
+		tab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)
+	}
+	ctrl := tab.Ctrl
+	plan := tabModeHasPlan(tab.mode)
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	if ctrl != nil {
+		ctrl.SetPlanMode(plan)
+		ctrl.SetGoal(goal)
+	}
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func (a *App) ClearGoal() {
+	a.SetGoal("")
+}
+
+func (a *App) ClearGoalForTab(tabID string) {
+	a.SetGoalForTab(tabID, "")
+}
+
+// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:
+// approval-gated tool calls run without asking, while ask questions and plan
+// approvals still wait for the user. Runtime-only — not written to config.
+func (a *App) SetAutoApproveTools(on bool) {
+	if on {
+		a.SetToolApprovalModeForTab("", control.ToolApprovalYolo)
+		return
+	}
+	a.SetToolApprovalModeForTab("", control.ToolApprovalAsk)
+}
+
+// SetBypass is the legacy Wails binding for SetAutoApproveTools.
+func (a *App) SetBypass(on bool) {
+	a.SetAutoApproveTools(on)
+}
+
+func (a *App) SetToolApprovalMode(mode string) {
+	a.SetToolApprovalModeForTab("", mode)
+}
+
+func (a *App) SetToolApprovalModeForTab(tabID, mode string) {
+	mode = normalizeToolApprovalMode(mode)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	tab.toolApprovalMode = mode
+	tab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)
+	ctrl := tab.Ctrl
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	applyTabToolApprovalModeToController(ctrl, mode)
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+// CommandInfo describes one available slash command for the composer's "/" menu.
+type CommandInfo struct {
+	Name        string `json:"name"` // without the leading slash
+	Description string `json:"description"`
+	Hint        string `json:"hint,omitempty"`  // argument hint, if any
+	Kind        string `json:"kind"`            // "builtin" | "custom" | "mcp" | "skill" | "subagent"
+	Group       string `json:"group,omitempty"` // menu group; older frontends can ignore it
+	Plugin      string `json:"plugin,omitempty"`
+	Color       string `json:"color,omitempty"`
+}
+
+// Commands lists the slash commands available this session — built-in actions,
+// custom commands (.reasonix/commands), and MCP prompts — for the composer's "/"
+// autocomplete menu.
+func (a *App) Commands() []CommandInfo {
+	out := []CommandInfo{
+		{Name: "new", Description: i18n.M.CmdNew, Kind: "builtin", Group: "actions"},
+		{Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin", Group: "actions"},
+		{Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin", Group: "actions"},
+		{Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions"},
+		{Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management"},
+		{Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions"},
+		{Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management"},
+		{Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management"},
+		{Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"},
+		{Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin", Group: "management"},
+		{Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin", Group: "integrations"},
+		{Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin", Group: "management"},
+		{Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin", Group: "integrations"},
+		{Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin", Group: "management"},
+		{Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin", Group: "skills"},
+		{Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin", Group: "management"},
+	}
+	a.mu.RLock()
+	ctrl := a.activeCtrlLocked()
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return out
+	}
+	// Skills are invocable as slash commands (the model runs inline ones; subagent ones
+	// run isolated). Listing them here is what surfaces /init, /explore, … in the
+	// composer's slash menu; selecting one submits its displayed slash name, which the controller
+	// resolves via RunSkill.
+	for _, s := range ctrl.SlashSkills() {
+		kind := "skill"
+		if s.RunAs == skill.RunSubagent {
+			kind = "subagent"
+		}
+		group := "skills"
+		if kind == "subagent" {
+			group = "subagents"
+		}
+		out = append(out, CommandInfo{Name: s.SlashName(), Description: s.Description, Kind: kind, Group: group, Plugin: s.Plugin, Color: s.Color})
+	}
+	for _, c := range ctrl.Commands() {
+		if c.Hidden {
+			continue
+		}
+		out = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: "custom", Group: "skills", Plugin: c.Plugin})
+	}
+	if h := ctrl.Host(); h != nil {
+		for _, p := range h.Prompts() {
+			out = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: "mcp", Group: "integrations"})
+		}
+	}
+	return out
+}
+
+// SlashArgItem is one sub-command / argument suggestion for the composer's slash
+// menu (the part after the command word). Mirrors the CLI's arg completion via
+// the shared control.SlashArgItems, so desktop and CLI offer the same hints.
+type SlashArgItem struct {
+	Label   string `json:"label"`
+	Insert  string `json:"insert"`
+	Hint    string `json:"hint"`
+	Descend bool   `json:"descend"`
+}
+
+// SlashArgsResult carries the suggestions plus the byte offset in the input where
+// the current token begins, so the composer replaces just that token.
+type SlashArgsResult struct {
+	Items []SlashArgItem `json:"items"`
+	From  int            `json:"from"`
+}
+
+// SlashArgs completes the arguments of a management slash command (/mcp, /model,
+// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty
+// Items means the input has no structured arguments to complete.
+func (a *App) SlashArgs(input string) SlashArgsResult {
+	a.mu.RLock()
+	ctrl := a.activeCtrlLocked()
+	model := ""
+	if tab := a.activeTabLocked(); tab != nil {
+		model = tab.model
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return SlashArgsResult{Items: []SlashArgItem{}}
+	}
+	data := control.ArgData{
+		Skills:          ctrl.Skills(),
+		DisabledSkills:  ctrl.DisabledSkills(),
+		ConfiguredMCP:   ctrl.ConfiguredMCPNames(),
+		DisconnectedMCP: ctrl.DisconnectedMCPNames(),
+		CurrentModel:    model,
+	}
+	if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil {
+		data.PluginNames = names
+	}
+	seen := map[string]bool{}
+	for _, m := range a.Models() {
+		data.ModelRefs = append(data.ModelRefs, m.Ref)
+		if m.Provider != "" && !seen[m.Provider] {
+			seen[m.Provider] = true
+			data.ProviderNames = append(data.ProviderNames, m.Provider)
+		}
+		if m.Current {
+			data.CurrentProvider = m.Provider
+		}
+	}
+	if h := ctrl.Host(); h != nil {
+		data.ServerNames = h.ServerNames()
+	}
+	items, from := control.SlashArgItems(input, data)
+	// Non-nil so it serializes as a JSON array, never null — the frontend filters
+	// over it directly.
+	out := SlashArgsResult{Items: []SlashArgItem{}, From: from}
+	for _, it := range items {
+		out.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})
+	}
+	return out
+}
+
+// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP
+// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.
+type CapabilitiesView struct {
+	Servers    []ServerView    `json:"servers"`
+	Skills     []SkillView     `json:"skills"`
+	SkillRoots []SkillRootView `json:"skillRoots"`
+	Plugins    []PluginView    `json:"plugins"`
+}
+
+// SkillsSettingsView is the skills management page's data, split from MCP
+// status so opening MCP settings does not scan skill roots.
+type SkillsSettingsView struct {
+	Skills     []SkillView     `json:"skills"`
+	SkillRoots []SkillRootView `json:"skillRoots"`
+}
+
+// ServerView is one MCP server for the drawer. Status is "connected" (with
+// tool/prompt/resource counts), "deferred" (enabled but idle), "failed" (with
+// the connection error), "initializing" (background startup in progress), or
+// "disabled".
+type ServerView struct {
+	Name                 string     `json:"name"`
+	Transport            string     `json:"transport"`
+	Status               string     `json:"status"`
+	StartIntent          string     `json:"startIntent,omitempty"`
+	RuntimeState         string     `json:"runtimeState,omitempty"`
+	BuiltIn              bool       `json:"builtIn,omitempty"`
+	Configured           bool       `json:"configured,omitempty"`
+	AutoStart            bool       `json:"autoStart"`
+	Tier                 string     `json:"tier,omitempty"`
+	Command              string     `json:"command,omitempty"`
+	Args                 []string   `json:"args,omitempty"`
+	URL                  string     `json:"url,omitempty"`
+	EnvKeys              []string   `json:"envKeys,omitempty"`
+	HeaderKeys           []string   `json:"headerKeys,omitempty"`
+	Tools                int        `json:"tools"`
+	Prompts              int        `json:"prompts"`
+	Resources            int        `json:"resources"`
+	HasTools             bool       `json:"hasTools,omitempty"`
+	Error                string     `json:"error,omitempty"`
+	ToolList             []ToolView `json:"toolList,omitempty"`
+	TrustedReadOnlyTools []string   `json:"trustedReadOnlyTools,omitempty"`
+	AuthStatus           string     `json:"authStatus,omitempty"`
+	AuthURL              string     `json:"authUrl,omitempty"`
+	AuthConfigured       bool       `json:"authConfigured,omitempty"`
+	ManagedByPlugin      string     `json:"managedByPlugin,omitempty"`
+}
+
+type ToolView struct {
+	Name         string `json:"name"`
+	Description  string `json:"description"`
+	ReadOnlyHint bool   `json:"readOnlyHint,omitempty"`
+}
+
+// SkillView is one discoverable skill for the drawer. Also backs the
+// Subagents settings surface: the frontend filters this same list to
+// RunAs=="subagent" rather than calling a second, redundant endpoint.
+type SkillView struct {
+	Name         string   `json:"name"`
+	Description  string   `json:"description"`
+	Scope        string   `json:"scope"`
+	RunAs        string   `json:"runAs"`
+	Enabled      bool     `json:"enabled"`
+	Plugin       string   `json:"plugin,omitempty"`
+	Model        string   `json:"model,omitempty"`
+	Effort       string   `json:"effort,omitempty"`
+	AllowedTools []string `json:"allowedTools,omitempty"`
+	Color        string   `json:"color,omitempty"`
+	// Invocation is the user-facing slash name; InvocationMode preserves the
+	// frontmatter policy used by the subagent profile editor.
+	Invocation     string `json:"invocation,omitempty"`
+	InvocationMode string `json:"invocationMode,omitempty"`
+	// Body is the skill's full markdown body (post-frontmatter) — the
+	// subagent profile editor pre-fills its system-prompt field from this.
+	Body string `json:"body,omitempty"`
+	// ConfiguredModel/ConfiguredEffort are the per-name overrides from
+	// cfg.Agent.SubagentModels/SubagentEfforts (internal/boot's
+	// subagentModelRef/subagentEffortRef read the same map at dispatch time).
+	// This is the only lever for a built-in subagent's model/effort, since
+	// built-ins have no editable frontmatter file to carry Model/Effort.
+	ConfiguredModel  string `json:"configuredModel,omitempty"`
+	ConfiguredEffort string `json:"configuredEffort,omitempty"`
+}
+
+type SkillRootSkillView struct {
+	Name         string   `json:"name"`
+	Description  string   `json:"description"`
+	Scope        string   `json:"scope"`
+	RunAs        string   `json:"runAs"`
+	Plugin       string   `json:"plugin,omitempty"`
+	Model        string   `json:"model,omitempty"`
+	Effort       string   `json:"effort,omitempty"`
+	AllowedTools []string `json:"allowedTools,omitempty"`
+	Color        string   `json:"color,omitempty"`
+	Invocation   string   `json:"invocation,omitempty"`
+}
+
+// SkillRootView is one skill discovery root for the drawer's Sources section.
+type SkillRootView struct {
+	Dir        string               `json:"dir"`
+	Scope      string               `json:"scope"`
+	Priority   int                  `json:"priority"`
+	Status     string               `json:"status"`
+	Configured bool                 `json:"configured"`
+	Removable  bool                 `json:"removable"`
+	Skills     int                  `json:"skills"`
+	SkillItems []SkillRootSkillView `json:"skillItems,omitempty"`
+	Warning    string               `json:"warning,omitempty"`
+}
+
+// Capabilities projects the session's MCP servers (connected + failed) and skills
+// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.
+func (a *App) Capabilities() CapabilitiesView {
+	skills := a.SkillsSettings()
+	return CapabilitiesView{
+		Servers:    a.MCPServers(),
+		Skills:     skills.Skills,
+		SkillRoots: skills.SkillRoots,
+		Plugins:    a.Plugins(),
+	}
+}
+
+// MCPServers returns only MCP server status for settings pages that do not need
+// skill discovery.
+func (a *App) MCPServers() []ServerView {
+	return a.mcpServersView()
+}
+
+// SkillsSettings returns the skills management snapshot without MCP status.
+func (a *App) SkillsSettings() SkillsSettingsView {
+	out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	var ctrl control.SessionAPI
+	if tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return out
+	}
+
+	disabled := map[string]bool{}
+	var configuredModels, configuredEfforts map[string]string
+	if cfg, err := config.Load(); err == nil {
+		for _, name := range cfg.Skills.DisabledSkills {
+			if key := config.SkillNameKey(name); key != "" {
+				disabled[key] = true
+			}
+		}
+		configuredModels = cfg.Agent.SubagentModels
+		configuredEfforts = cfg.Agent.SubagentEfforts
+	}
+	for _, s := range ctrl.AllSkills() {
+		view := SkillView{
+			Name: s.Name, Description: s.Description,
+			Scope: string(s.Scope), RunAs: string(s.RunAs),
+			Enabled:          !disabled[config.SkillNameKey(s.Name)],
+			Plugin:           s.Plugin,
+			Model:            s.Model,
+			Effort:           s.Effort,
+			AllowedTools:     append([]string{}, s.AllowedTools...),
+			Color:            s.Color,
+			Invocation:       "/" + s.SlashName(),
+			InvocationMode:   s.Invocation,
+			ConfiguredModel:  subagentOverrideFor(configuredModels, s.Name),
+			ConfiguredEffort: subagentOverrideFor(configuredEfforts, s.Name),
+		}
+		// Body feeds only the Subagents editor's prompt prefill. Inline skills
+		// fold references/ into Body at load time (hundreds of KB for a rich
+		// skill library), and every Capabilities/Settings fetch would ship all
+		// of it across the JSON bridge for nothing.
+		if s.RunAs == skill.RunSubagent {
+			view.Body = s.Body
+		}
+		out.Skills = append(out.Skills, view)
+	}
+	out.SkillRoots = a.cachedSkillRootsView()
+	return out
+}
+
+// subagentOverrideFor resolves a per-name subagent override with the same
+// underscore/hyphen alias fallback the runtime dispatch uses
+// (boot.SubagentModelKeys) — an exact-key read would show a legacy
+// `security_review` config entry as "inherit default" while it still won at
+// dispatch time.
+func subagentOverrideFor(overrides map[string]string, name string) string {
+	for _, key := range boot.SubagentModelKeys(name) {
+		if v := strings.TrimSpace(overrides[key]); v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+// AvailableSubagentTools lists the tool names a subagent profile's
+// "available tools" picker may offer. Scoped to compile-time builtins for
+// v1 — MCP/plugin tools are per-session/per-connection and would need a new
+// live-registry accessor on control.Capabilities to enumerate safely; a
+// profile's allowed-tools already degrades gracefully (FilterRegistry drops
+// unknown names silently) if extended to MCP names by hand later. Tools that
+// are always excluded from every subagent regardless of an explicit
+// allowlist (agent.AlwaysHiddenSubagentTools) are left out entirely — they'd
+// be a selectable no-op otherwise.
+func (a *App) AvailableSubagentTools() []ToolView {
+	hidden := map[string]bool{}
+	for _, name := range agent.AlwaysHiddenSubagentTools() {
+		hidden[name] = true
+	}
+	entries := tool.BuiltinContractEntries()
+	out := make([]ToolView, 0, len(entries))
+	for _, e := range entries {
+		if hidden[e.Name] {
+			continue
+		}
+		out = append(out, ToolView{Name: e.Name, Description: e.Description, ReadOnlyHint: e.ReadOnly})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+	return out
+}
+
+func (a *App) mcpServersView() []ServerView {
+	out := []ServerView{}
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	if tab == nil {
+		a.mu.RUnlock()
+		return out
+	}
+	ctrl := tab.Ctrl
+	disabled := make(map[string]ServerView, len(tab.disabledMCP))
+	for name, s := range tab.disabledMCP {
+		disabled[name] = s
+	}
+	order := append([]string(nil), tab.mcpOrder...)
+	workspaceRoot := tab.WorkspaceRoot
+	tabID := tab.ID
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return out
+	}
+	seen := map[string]bool{}
+	connected := map[string]bool{}
+	retainedDisabled := map[string]ServerView{}
+	configured := map[string]config.PluginEntry{}
+	managedByPlugin := map[string]string{}
+	var configuredEntries []config.PluginEntry
+	if cfg, err := config.LoadForRoot(workspaceRoot); err == nil {
+		configuredEntries = append(configuredEntries, cfg.Plugins...)
+		for _, p := range configuredEntries {
+			configured[p.Name] = p
+			if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
+				managedByPlugin[p.Name] = owner
+			}
+		}
+	}
+	if h := ctrl.Host(); h != nil {
+		for _, s := range h.Servers() {
+			if disabledView, ok := disabled[s.Name]; ok {
+				disabledView.Status = "disabled"
+				disabledView.RuntimeState = "idle"
+				disabledView.StartIntent = "off"
+				disabledView.Error = ""
+				if p, ok := configured[s.Name]; ok {
+					disabledView = withPluginConfig(disabledView, p)
+				}
+				out = append(out, disabledView)
+				retainedDisabled[s.Name] = disabledView
+				seen[s.Name] = true
+				delete(disabled, s.Name)
+				continue
+			}
+			seen[s.Name] = true
+			connected[s.Name] = true
+			view := ServerView{
+				Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready",
+				Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
+				HasTools: s.HasTools,
+				ToolList: pluginToolsToView(s.ToolList),
+			}
+			if p, ok := configured[s.Name]; ok {
+				view = withPluginConfig(view, p)
+			}
+			out = append(out, view)
+		}
+		for _, f := range h.Failures() {
+			seen[f.Name] = true
+			view := ServerView{
+				Name: f.Name, Transport: f.Transport, Status: "failed", RuntimeState: "issue", Error: f.Error,
+			}
+			if p, ok := configured[f.Name]; ok {
+				view = withPluginConfig(view, p)
+			}
+			out = append(out, view)
+		}
+		for _, name := range h.ConnectingServers() {
+			if seen[name] {
+				continue
+			}
+			seen[name] = true
+			view := ServerView{Name: name, Status: "initializing", RuntimeState: "connecting"}
+			if p, ok := configured[name]; ok {
+				view = withPluginConfig(view, p)
+			}
+			out = append(out, view)
+		}
+	}
+	// Configured servers that are neither connected, connecting, nor failed are
+	// idle: disabled/off or automatic background startup waiting for its next kick.
+	if len(configuredEntries) > 0 {
+		for _, p := range configuredEntries {
+			if seen[p.Name] {
+				continue
+			}
+			if s, ok := disabled[p.Name]; ok {
+				s.Status = "disabled"
+				s.RuntimeState = "idle"
+				s.StartIntent = "off"
+				s = withPluginConfig(s, p)
+				s.Error = ""
+				out = append(out, s)
+				retainedDisabled[p.Name] = s
+				seen[p.Name] = true
+				delete(disabled, p.Name)
+				continue
+			}
+			status := "disabled"
+			startIntent := "off"
+			if p.ShouldAutoStart() {
+				status = "deferred"
+				startIntent = mcpStartIntent(p)
+			}
+			out = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: "idle"}, p))
+			seen[p.Name] = true
+		}
+	}
+	out = orderServerViews(out, order)
+	for i := range out {
+		out[i].ManagedByPlugin = managedByPlugin[out[i].Name]
+	}
+
+	a.mu.Lock()
+	if tab, ok := a.tabs[tabID]; ok {
+		for name := range connected {
+			delete(retainedDisabled, name)
+		}
+		tab.disabledMCP = retainedDisabled
+		tab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)
+	}
+	a.mu.Unlock()
+	return out
+}
+
+func mcpStartIntent(p config.PluginEntry) string {
+	if !p.ShouldAutoStart() {
+		return "off"
+	}
+	return "automatic"
+}
+
+func mcpRuntimeState(status string) string {
+	switch status {
+	case "connected":
+		return "ready"
+	case "initializing":
+		return "connecting"
+	case "failed":
+		return "issue"
+	default:
+		return "idle"
+	}
+}
+
+func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
+	tt := p.Type
+	if tt == "" {
+		tt = "stdio"
+	}
+	v.Transport = tt
+	v.Configured = true
+	v.AutoStart = p.ShouldAutoStart()
+	v.Tier = p.ResolvedTier()
+	if v.StartIntent == "" {
+		v.StartIntent = mcpStartIntent(p)
+	}
+	if v.Status == "disabled" {
+		v.StartIntent = "off"
+	}
+	if v.RuntimeState == "" {
+		v.RuntimeState = mcpRuntimeState(v.Status)
+	}
+	v.Command = p.Command
+	v.Args = append([]string(nil), p.Args...)
+	v.URL = p.URL
+	v.TrustedReadOnlyTools = uniqueStrings(p.TrustedReadOnlyTools)
+	v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
+	v.EnvKeys = nil
+	v.HeaderKeys = nil
+	if len(p.Env) > 0 {
+		v.EnvKeys = make([]string, 0, len(p.Env))
+		for k := range p.Env {
+			v.EnvKeys = append(v.EnvKeys, k)
+		}
+		sort.Strings(v.EnvKeys)
+	}
+	if len(p.Headers) > 0 {
+		v.HeaderKeys = make([]string, 0, len(p.Headers))
+		for k := range p.Headers {
+			v.HeaderKeys = append(v.HeaderKeys, k)
+		}
+		sort.Strings(v.HeaderKeys)
+	}
+	auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)
+	v.AuthStatus = auth.Status
+	v.AuthURL = auth.URL
+	return v
+}
+
+const skillRootsCacheTTL = 10 * time.Second
+
+func (a *App) cachedSkillRootsView() []SkillRootView {
+	cwd, _ := os.Getwd()
+	cfg, _ := config.Load()
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	key := skillRootsCacheKey(cwd, cfg, userCfg)
+
+	now := time.Now()
+	a.skillRootsMu.Lock()
+	if a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {
+		roots := cloneSkillRootViews(a.skillRootsCache.roots)
+		a.skillRootsMu.Unlock()
+		return roots
+	}
+	a.skillRootsMu.Unlock()
+
+	roots := skillRootsViewFrom(cwd, cfg, userCfg)
+
+	a.skillRootsMu.Lock()
+	a.skillRootsCache = skillRootsCache{
+		key:   key,
+		at:    now,
+		roots: cloneSkillRootViews(roots),
+	}
+	a.skillRootsMu.Unlock()
+	return roots
+}
+
+func (a *App) invalidateSkillRootsCache() {
+	a.skillRootsMu.Lock()
+	a.skillRootsCache = skillRootsCache{}
+	a.skillRootsMu.Unlock()
+}
+
+func skillRootsView() []SkillRootView {
+	cwd, _ := os.Getwd()
+	cfg, _ := config.Load()
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	return skillRootsViewFrom(cwd, cfg, userCfg)
+}
+
+func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {
+	var custom []string
+	var excluded []string
+	maxDepth := 3
+	if cfg != nil {
+		custom = cfg.SkillCustomPaths()
+		excluded = cfg.SkillExcludedPaths()
+		maxDepth = cfg.SkillMaxDepth()
+	}
+	var pluginPaths map[string][]string
+	if cfg != nil {
+		pluginPaths = cfg.PluginPackageSkillOwners()
+	}
+	st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
+	counts := map[string]int{}
+	skillItems := map[string][]SkillRootSkillView{}
+	roots := st.Roots()
+	for _, sk := range st.SlashList() {
+		root := skillDisplayRoot(sk, roots)
+		counts[root]++
+		skillItems[root] = append(skillItems[root], SkillRootSkillView{
+			Name:         sk.Name,
+			Description:  sk.Description,
+			Scope:        string(sk.Scope),
+			RunAs:        string(sk.RunAs),
+			Plugin:       sk.Plugin,
+			Model:        sk.Model,
+			Effort:       sk.Effort,
+			AllowedTools: append([]string{}, sk.AllowedTools...),
+			Color:        sk.Color,
+			Invocation:   "/" + sk.SlashName(),
+		})
+	}
+	for root := range skillItems {
+		sort.Slice(skillItems[root], func(i, j int) bool {
+			return skillItems[root][i].Invocation < skillItems[root][j].Invocation
+		})
+	}
+	userConfigured := map[string]bool{}
+	if userCfg != nil {
+		for _, p := range userCfg.Skills.Paths {
+			userConfigured[config.CanonicalSkillPath(p)] = true
+		}
+	}
+	out := []SkillRootView{}
+	seenRoots := map[string]int{}
+	for _, r := range roots {
+		dir := config.CanonicalSkillPath(r.Dir)
+		view := SkillRootView{
+			Dir:        r.Dir,
+			Scope:      string(r.Scope),
+			Priority:   r.Priority + 1,
+			Status:     string(r.Status),
+			Configured: r.Scope == skill.ScopeCustom && userConfigured[dir],
+			Removable:  true,
+			Skills:     counts[dir],
+			SkillItems: skillItems[dir],
+		}
+		if idx, ok := seenRoots[dir]; ok {
+			out[idx] = mergeDuplicateSkillRootView(out[idx], view)
+			continue
+		}
+		seenRoots[dir] = len(out)
+		out = append(out, view)
+	}
+	if userCfg != nil {
+		for _, p := range userCfg.Skills.Paths {
+			if rootActive(out, p) {
+				continue
+			}
+			out = append(out, SkillRootView{
+				Dir:        p,
+				Scope:      string(skill.ScopeCustom),
+				Status:     "inactive",
+				Configured: true,
+				Removable:  true,
+				Warning:    "configured in user config but not active in this workspace; project [skills].paths may override it",
+			})
+		}
+	}
+	return out
+}
+
+func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {
+	existing.Configured = existing.Configured || duplicate.Configured
+	existing.Removable = existing.Removable || duplicate.Removable
+	if existing.Status != "ok" && duplicate.Status == "ok" {
+		existing.Status = duplicate.Status
+	}
+	if existing.Skills == 0 && duplicate.Skills > 0 {
+		existing.Skills = duplicate.Skills
+		existing.SkillItems = duplicate.SkillItems
+	}
+	if existing.Warning == "" {
+		existing.Warning = duplicate.Warning
+	}
+	return existing
+}
+
+func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
+	type cacheKey struct {
+		CWD       string   `json:"cwd"`
+		Custom    []string `json:"custom"`
+		Plugins   []string `json:"plugins"`
+		Excluded  []string `json:"excluded"`
+		MaxDepth  int      `json:"maxDepth"`
+		UserPaths []string `json:"userPaths"`
+	}
+	key := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}
+	if cfg != nil {
+		key.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())
+		for path, owners := range cfg.PluginPackageSkillOwners() {
+			for _, owner := range owners {
+				key.Plugins = append(key.Plugins, config.CanonicalSkillPath(path)+"\x00"+owner)
+			}
+		}
+		sort.Strings(key.Plugins)
+		key.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())
+		key.MaxDepth = cfg.SkillMaxDepth()
+	}
+	if userCfg != nil {
+		key.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)
+	}
+	b, err := json.Marshal(key)
+	if err != nil {
+		return fmt.Sprintf("%s|%v|%v|%v|%d|%v", key.CWD, key.Custom, key.Plugins, key.Excluded, key.MaxDepth, key.UserPaths)
+	}
+	return string(b)
+}
+
+func canonicalSkillPaths(paths []string) []string {
+	out := make([]string, 0, len(paths))
+	for _, p := range paths {
+		out = append(out, config.CanonicalSkillPath(p))
+	}
+	sort.Strings(out)
+	return out
+}
+
+func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
+	out := make([]SkillRootView, len(in))
+	for i, r := range in {
+		out[i] = r
+		out[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)
+	}
+	return out
+}
+
+func rootActive(roots []SkillRootView, path string) bool {
+	want := config.CanonicalSkillPath(path)
+	for _, r := range roots {
+		if config.CanonicalSkillPath(r.Dir) == want {
+			return true
+		}
+	}
+	return false
+}
+
+// PickSkillFolder opens a directory picker for adding custom skill roots. It only
+// returns a path; AddSkillPath performs normalization and writes config.
+func (a *App) PickSkillFolder() (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	cur, _ := os.Getwd()
+	dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+		Title:            "Choose skills folder",
+		DefaultDirectory: dialogDefaultDirectory(cur),
+	})
+	if err != nil || dir == "" {
+		return "", err
+	}
+	return normalizeSkillPath(dir), nil
+}
+
+// PickPluginFolder opens a directory picker for choosing a local plugin package
+// source. It returns the selected directory path; plugin install/plan performs
+// manifest validation and decides whether to copy or link the package.
+func (a *App) PickPluginFolder() (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	cur := a.activeWorkspaceRoot()
+	if strings.TrimSpace(cur) == "" {
+		cur, _ = os.Getwd()
+	}
+	dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+		Title:            "Choose plugin folder",
+		DefaultDirectory: dialogDefaultDirectory(cur),
+	})
+	if err != nil || dir == "" {
+		return "", err
+	}
+	return filepath.Clean(dir), nil
+}
+
+// AddSkillPath adds a custom skill root to the user config and rebuilds the
+// controller so the skills index and slash menu reflect it immediately.
+func (a *App) AddSkillPath(path string) error {
+	path = normalizeSkillPath(path)
+	workspaceRoot := a.activeWorkspaceRoot()
+	err := a.applyConfigChange(func(c *config.Config) error {
+		if isConventionSkillRoot(path, workspaceRoot) {
+			return c.RestoreSkillPath(path)
+		}
+		return c.AddSkillPath(path)
+	})
+	if err == nil {
+		a.invalidateSkillRootsCache()
+	}
+	return err
+}
+
+// RemoveSkillPath removes a skill source from the user config and rebuilds. For
+// convention roots, it records a pseudo-delete in excluded_paths.
+func (a *App) RemoveSkillPath(path string) error {
+	path = normalizeSkillPath(path)
+	err := a.applyConfigChange(func(c *config.Config) error {
+		removed, err := c.RemoveSkillPath(path)
+		if err != nil || removed {
+			return err
+		}
+		return c.ExcludeSkillPath(path)
+	})
+	if err == nil {
+		a.invalidateSkillRootsCache()
+	}
+	return err
+}
+
+// RefreshSkills rebuilds the controller without changing config, reloading skill
+// discovery, the system prompt index, and slash completions.
+func (a *App) RefreshSkills() error {
+	a.invalidateSkillRootsCache()
+	if err := a.rebuild(); err != nil {
+		// The skill cache is already invalidated; refresh the runtime once the
+		// other window releases the session lease.
+		if _, ok := a.deferredRebuildWarning("skills", err); ok {
+			return nil
+		}
+		return err
+	}
+	return nil
+}
+
+// ReloadCommands rescans command directories and hot-swaps without restarting
+// the controller — no MCP disconnect, no hook rerun.
+func (a *App) ReloadCommands() error {
+	if a.ctx == nil {
+		return nil
+	}
+	_, ctrl := a.activeTabAndCtrl()
+	if ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if ctrl.Running() {
+		return fmt.Errorf("wait for the current turn to finish, then retry")
+	}
+	return ctrl.ReloadCommands(a.ctx)
+}
+
+// SetSkillEnabled persists a skill toggle and rebuilds the controller so the
+// prompt index, slash menu, and skill tools reflect it immediately.
+func (a *App) SetSkillEnabled(name string, enabled bool) error {
+	err := a.applyConfigChange(func(c *config.Config) error {
+		return c.SetSkillEnabled(name, enabled)
+	})
+	if err == nil {
+		a.invalidateSkillRootsCache()
+	}
+	return err
+}
+
+func normalizeSkillPath(path string) string {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return ""
+	}
+	if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
+		if home, err := os.UserHomeDir(); err == nil {
+			if path == "~" {
+				path = home
+			} else {
+				path = filepath.Join(home, path[2:])
+			}
+		}
+	}
+	if abs, err := filepath.Abs(path); err == nil {
+		path = abs
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		return filepath.Clean(path)
+	}
+	if info.Mode().IsRegular() {
+		if filepath.Base(path) == skill.SkillFile {
+			return filepath.Clean(filepath.Dir(filepath.Dir(path)))
+		}
+		return filepath.Clean(filepath.Dir(path))
+	}
+	if info.IsDir() {
+		if _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {
+			return filepath.Clean(filepath.Dir(path))
+		}
+	}
+	return filepath.Clean(path)
+}
+
+func isConventionSkillRoot(path, workspaceRoot string) bool {
+	want := config.CanonicalSkillPath(path)
+	if want == "" {
+		return false
+	}
+	bases := []string{workspaceRoot}
+	if home, err := os.UserHomeDir(); err == nil {
+		bases = append(bases, home)
+	}
+	for _, base := range bases {
+		base = strings.TrimSpace(base)
+		if base == "" {
+			continue
+		}
+		for _, dir := range config.ConventionDirs {
+			if want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func skillRootPath(path string) string {
+	if filepath.Base(path) == skill.SkillFile {
+		return filepath.Dir(path)
+	}
+	return path
+}
+
+func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
+	cleanPath := filepath.Clean(sk.Path)
+	for _, r := range roots {
+		if r.Scope != sk.Scope {
+			continue
+		}
+		cleanRoot := filepath.Clean(r.Dir)
+		prefix := cleanRoot + string(filepath.Separator)
+		if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {
+			return config.CanonicalSkillPath(r.Dir)
+		}
+	}
+	return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
+}
+
+// MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
+// + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
+type MCPServerInput struct {
+	Name                 string            `json:"name"`
+	Transport            string            `json:"transport"`
+	Command              string            `json:"command"`
+	Args                 []string          `json:"args"`
+	URL                  string            `json:"url"`
+	Env                  map[string]string `json:"env"`
+	Headers              map[string]string `json:"headers"`
+	TrustedReadOnlyTools []string          `json:"trustedReadOnlyTools"`
+}
+
+// AddMCPServer connects a server live and persists it to config (Customize → MCP →
+// Add). Returns the number of tools it exposed.
+func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
+	ctrl := a.activeCtrl()
+	if ctrl == nil {
+		return 0, fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return 0, rebuildControllerActiveWorkError("MCP server")
+	}
+	entry := config.PluginEntry{
+		Name:                 in.Name,
+		Type:                 normalizeMCPTransport(in.Transport),
+		Command:              in.Command,
+		Args:                 in.Args,
+		URL:                  in.URL,
+		Env:                  in.Env,
+		Headers:              in.Headers,
+		TrustedReadOnlyTools: uniqueStrings(in.TrustedReadOnlyTools),
+	}
+	entry, _ = config.NormalizePluginCommandLine(entry)
+	if err := a.saveDesktopMCPServer(entry); err != nil {
+		return 0, err
+	}
+	return ctrl.ConnectMCPServer(entry)
+}
+
+// UpdateMCPServer edits a persisted external MCP server. The name is the stable
+// identity; callers must remove + add if they want to rename a server.
+func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
+	ctrl := a.activeCtrl()
+	if ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	if strings.TrimSpace(in.Name) != "" && strings.TrimSpace(in.Name) != name {
+		return fmt.Errorf("renaming MCP servers is not supported; remove and add a new server")
+	}
+	updated, found, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("no configured MCP server named %q", name)
+	}
+	updated.Type = normalizeMCPTransport(in.Transport)
+	updated.Command = strings.TrimSpace(in.Command)
+	updated.Args = append([]string(nil), in.Args...)
+	updated.URL = strings.TrimSpace(in.URL)
+	updated.Tier = ""
+	if in.Env != nil {
+		updated.Env = in.Env
+	}
+	if in.Headers != nil {
+		updated.Headers = in.Headers
+	}
+	if in.TrustedReadOnlyTools != nil {
+		updated.TrustedReadOnlyTools = uniqueStrings(in.TrustedReadOnlyTools)
+	}
+	updated, _ = config.NormalizePluginCommandLine(updated)
+	if updated.Type == "stdio" {
+		updated.URL = ""
+	} else {
+		updated.Command = ""
+		updated.Args = nil
+	}
+	if err := a.saveDesktopMCPServer(updated); err != nil {
+		return err
+	}
+
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	sessionDisabled := false
+	if tab != nil {
+		_, sessionDisabled = tab.disabledMCP[name]
+	}
+	a.mu.RUnlock()
+	wasConnected := mcpConnected(ctrl, name)
+	if wasConnected {
+		ctrl.DisconnectMCPServer(name)
+	}
+	if !sessionDisabled {
+		if _, err := ctrl.ConnectMCPServer(updated); err != nil {
+			recordMCPFailure(ctrl, updated, err)
+			return nil
+		}
+	}
+	return nil
+}
+
+// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
+func (a *App) RemoveMCPServer(name string) error {
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil {
+		return err
+	}
+	removed, err := a.removeDesktopMCPServer(tab.WorkspaceRoot, name)
+	if err != nil {
+		return err
+	}
+	if !removed {
+		return fmt.Errorf("no removable MCP server named %q", name)
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	a.mu.Lock()
+	delete(tab.disabledMCP, name)
+	tab.mcpOrder = removeServerOrder(tab.mcpOrder, name)
+	a.mu.Unlock()
+	return nil
+}
+
+// ReconnectMCPServer disconnects the server if it is already connected (to force
+// a fresh handshake and tool re-registration), then reconnects.  Failures are
+// recorded on the Host so the UI can render them.
+func (a *App) ReconnectMCPServer(name string) error {
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	_, err := a.connectConfiguredMCPServerForTab(tab, name)
+	if err != nil {
+		if plugin.IsServerAlreadyConnected(err) {
+			a.mu.Lock()
+			delete(tab.disabledMCP, name)
+			a.mu.Unlock()
+			return nil
+		}
+		entry := config.PluginEntry{Name: name}
+		if p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {
+			entry = p
+		}
+		recordMCPFailure(ctrl, entry, err)
+		return err
+	}
+	a.mu.Lock()
+	delete(tab.disabledMCP, name)
+	a.mu.Unlock()
+	return nil
+}
+
+// ClearMCPServerAuthentication removes local auth-like config for one MCP and
+// clears the current session's cached connection failure. It does not remove the
+// server itself or try to sign the user out of the third-party browser session.
+func (a *App) ClearMCPServerAuthentication(name string) error {
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil {
+		return err
+	}
+	if _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {
+		return err
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	return nil
+}
+
+func (a *App) updateMCPServerTrustedReadOnlyTools(name string, update func([]string) []string) error {
+	ctrl := a.activeCtrl()
+	if ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return fmt.Errorf("MCP server name is required")
+	}
+	updated, found, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("no configured MCP server named %q", name)
+	}
+	trusted := uniqueStrings(updated.TrustedReadOnlyTools)
+	next := uniqueStrings(update(trusted))
+	if sameStringList(trusted, next) {
+		updated.TrustedReadOnlyTools = trusted
+		return nil
+	}
+	updated.TrustedReadOnlyTools = next
+	if err := a.saveDesktopMCPServer(updated); err != nil {
+		return err
+	}
+
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	sessionDisabled := false
+	if tab != nil {
+		_, sessionDisabled = tab.disabledMCP[name]
+	}
+	a.mu.RUnlock()
+	if !mcpConnected(ctrl, name) {
+		return nil
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	if !sessionDisabled {
+		if _, err := ctrl.ConnectMCPServer(updated); err != nil {
+			recordMCPFailure(ctrl, updated, err)
+			return nil
+		}
+	}
+	return nil
+}
+
+// TrustMCPServerTool marks one raw MCP tool name as trusted read-only and
+// refreshes the live connection so plan mode can use the updated trust boundary.
+func (a *App) TrustMCPServerTool(name, toolName string) error {
+	toolName = strings.TrimSpace(toolName)
+	if toolName == "" {
+		return fmt.Errorf("MCP tool name is required")
+	}
+	return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
+		return append(trusted, toolName)
+	})
+}
+
+// TrustMCPServerTools marks multiple raw MCP tool names as trusted read-only in
+// one config write and one live reconnect.
+func (a *App) TrustMCPServerTools(name string, toolNames []string) error {
+	if len(uniqueStrings(toolNames)) == 0 {
+		return fmt.Errorf("at least one MCP tool name is required")
+	}
+	return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
+		return append(trusted, toolNames...)
+	})
+}
+
+// UntrustMCPServerTool removes one raw MCP tool name from the trusted read-only
+// list and refreshes the live connection.
+func (a *App) UntrustMCPServerTool(name, toolName string) error {
+	toolName = strings.TrimSpace(toolName)
+	if toolName == "" {
+		return fmt.Errorf("MCP tool name is required")
+	}
+	return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
+		return removeString(trusted, toolName)
+	})
+}
+
+// SetMCPServerEnabled is the connector toggle: on reconnects a configured server
+// for this session, off disconnects it (config untouched either way — like Claude
+// Code's per-conversation enable/disable, it resets on the next session start).
+func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	var ctrl control.SessionAPI
+	hostKey := ""
+	if tab != nil {
+		ctrl = tab.Ctrl
+		hostKey = tab.SharedHostKey
+	}
+	a.mu.RUnlock()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	configuredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if enabled {
+		_, err := a.connectConfiguredMCPServerForTab(tab, name)
+		if err == nil {
+			a.mu.Lock()
+			delete(tab.disabledMCP, name)
+			a.mu.Unlock()
+		}
+		return err
+	}
+	if s, ok := findMCPServerView(ctrl, name); ok {
+		s.Status = "disabled"
+		s.Error = ""
+		a.mu.Lock()
+		if tab.disabledMCP == nil {
+			tab.disabledMCP = map[string]ServerView{}
+		}
+		tab.disabledMCP[name] = s
+		tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
+		a.mu.Unlock()
+	} else if hasConfiguredEntry {
+		s := withPluginConfig(ServerView{Name: name, Status: "disabled"}, configuredEntry)
+		a.mu.Lock()
+		if tab.disabledMCP == nil {
+			tab.disabledMCP = map[string]ServerView{}
+		}
+		tab.disabledMCP[name] = s
+		tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
+		a.mu.Unlock()
+	}
+	if hostKey != "" {
+		ctrl.UnregisterMCPServerTools(name)
+	} else {
+		ctrl.DisconnectMCPServer(name)
+	}
+	return nil
+}
+
+func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {
+	a.mu.RLock()
+	var ctrl control.SessionAPI
+	root := ""
+	if tab != nil {
+		ctrl = tab.Ctrl
+		root = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return 0, fmt.Errorf("no active session")
+	}
+	cfg, err := config.LoadForRoot(root)
+	if err != nil {
+		return 0, err
+	}
+	for _, p := range cfg.Plugins {
+		if p.Name == name {
+			return ctrl.ConnectMCPServer(p)
+		}
+	}
+	return 0, fmt.Errorf("no configured MCP server named %q", name)
+}
+
+// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
+// retired tier field.
+func (a *App) SetMCPServerTier(name, tier string) error {
+	tier = normalizeMCPTier(tier)
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab != nil && controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	updated, found, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("no configured MCP server named %q", name)
+	}
+	updated.Tier = tier
+	if !updated.ShouldAutoStart() {
+		on := true
+		updated.AutoStart = &on
+	}
+	if err := a.saveDesktopMCPServer(updated); err != nil {
+		return err
+	}
+	if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
+		if _, err := ctrl.ConnectMCPServer(updated); err != nil {
+			recordMCPFailure(ctrl, updated, err)
+			return nil
+		}
+		a.mu.Lock()
+		delete(tab.disabledMCP, name)
+		a.mu.Unlock()
+	}
+	return nil
+}
+
+func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {
+	// Read-only lookup of the entry to edit; loads credentials because callers
+	// hand the entry to ConnectMCPServer, which resolves env-based secrets.
+	// The actual config write goes through saveDesktopMCPServer under the
+	// config edit lock.
+	cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
+	if err != nil {
+		return config.PluginEntry{}, false, err
+	}
+	if p, ok := findPluginEntry(cfg.Plugins, name); ok {
+		return p, true, nil
+	}
+	if merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {
+		if p, ok := findPluginEntry(merged.Plugins, name); ok {
+			return p, true, nil
+		}
+	}
+	return config.PluginEntry{}, false, nil
+}
+
+func (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {
+	root := a.activeWorkspaceRoot()
+	if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
+		return err
+	}
+	if a.desktopMCPServerOwnedByProjectMCPJSON(root, entry.Name) {
+		_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(root), entry)
+		return err
+	}
+	// Lock only the user-config load-modify-save; the project-override cleanup
+	// below writes the project config, which this lock does not cover.
+	if err := func() error {
+		unlock := config.LockUserConfigEdits()
+		defer unlock()
+		cfg, path, err := a.loadDesktopUserConfigForEdit()
+		if err != nil {
+			return err
+		}
+		if err := cfg.UpsertPlugin(entry); err != nil {
+			return err
+		}
+		return cfg.SaveTo(path)
+	}(); err != nil {
+		return err
+	}
+	_, err := a.removeProjectMCPOverride(root, entry.Name)
+	return err
+}
+
+func ensureMCPServerDirectlyWritable(root, name string) error {
+	cfg, err := config.LoadForRoot(root)
+	if err != nil {
+		return err
+	}
+	if owner, ok := cfg.PluginPackageOwner(name); ok {
+		return fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
+	}
+	return nil
+}
+
+func (a *App) removeDesktopMCPServer(root, name string) (bool, error) {
+	return config.RemovePluginFromSourcesForRoot(root, name)
+}
+
+func (a *App) removeProjectMCPOverride(root, name string) (bool, error) {
+	path := projectConfigPathForRoot(root)
+	userPath := config.UserConfigPath()
+	if path == "" || sameConfigPath(path, userPath) {
+		return false, nil
+	}
+	if _, err := os.Stat(path); err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		}
+		return false, err
+	}
+	cfg := config.LoadForEdit(path)
+	if !cfg.RemovePlugin(name) {
+		return false, nil
+	}
+	if err := cfg.SaveTo(path); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+func (a *App) desktopMCPServerOwnedByProjectMCPJSON(root, name string) bool {
+	if strings.TrimSpace(name) == "" {
+		return false
+	}
+	// Read-only ownership check: only looks for the name in the user config's
+	// plugin list, so no credentials and no config edit lock are needed.
+	cfg, _, err := a.loadDesktopUserConfigForView()
+	if err == nil {
+		if _, ok := findPluginEntry(cfg.Plugins, name); ok {
+			return false
+		}
+	}
+	projectCfg := config.LoadForEdit(projectConfigPathForRoot(root))
+	if _, ok := findPluginEntry(projectCfg.Plugins, name); ok {
+		return false
+	}
+	_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(root), name)
+	return err == nil && ok
+}
+
+func projectMCPJSONPathForRoot(root string) string {
+	if strings.TrimSpace(root) == "" || root == "." {
+		return ".mcp.json"
+	}
+	return filepath.Join(root, ".mcp.json")
+}
+
+func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
+	for _, p := range entries {
+		if p.Name == name {
+			return p, true
+		}
+	}
+	return config.PluginEntry{}, false
+}
+
+func normalizeMCPTier(tier string) string {
+	switch strings.ToLower(strings.TrimSpace(tier)) {
+	case "eager":
+		return "eager"
+	case "background", "lazy":
+		return "background"
+	case "":
+		return "background"
+	default:
+		return "background"
+	}
+}
+
+func normalizeMCPTransport(transport string) string {
+	switch strings.ToLower(strings.TrimSpace(transport)) {
+	case "http", "streamable-http":
+		return "http"
+	case "sse":
+		return "sse"
+	default:
+		return "stdio"
+	}
+}
+
+func mcpConnected(ctrl control.SessionAPI, name string) bool {
+	if ctrl == nil || ctrl.Host() == nil {
+		return false
+	}
+	for _, s := range ctrl.Host().Servers() {
+		if s.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func mcpFailed(ctrl control.SessionAPI, name string) bool {
+	if ctrl == nil || ctrl.Host() == nil {
+		return false
+	}
+	for _, f := range ctrl.Host().Failures() {
+		if f.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {
+	if ctrl == nil || ctrl.Host() == nil || err == nil {
+		return
+	}
+	exp := e.ExpandedPlugin()
+	ctrl.Host().RecordFailure(plugin.Spec{
+		Name:    exp.Name,
+		Type:    exp.Type,
+		Command: exp.Command,
+		Args:    exp.Args,
+		Env:     exp.Env,
+		URL:     exp.URL,
+		Headers: exp.Headers,
+	}, err)
+}
+
+func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {
+	if ctrl == nil || ctrl.Host() == nil {
+		return ServerView{}, false
+	}
+	for _, s := range ctrl.Host().Servers() {
+		if s.Name == name {
+			return ServerView{
+				Name: s.Name, Transport: s.Transport, Status: "connected",
+				Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
+				HasTools: s.HasTools,
+				ToolList: pluginToolsToView(s.ToolList),
+			}, true
+		}
+	}
+	for _, f := range ctrl.Host().Failures() {
+		if f.Name == name {
+			return ServerView{Name: f.Name, Transport: f.Transport, Status: "failed", Error: f.Error}, true
+		}
+	}
+	return ServerView{}, false
+}
+
+func pluginToolsToView(tools []plugin.ToolInfo) []ToolView {
+	if len(tools) == 0 {
+		return nil
+	}
+	out := make([]ToolView, 0, len(tools))
+	for _, t := range tools {
+		out = append(out, ToolView{Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint})
+	}
+	return out
+}
+
+func sameStringList(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
+
+func orderServerViews(servers []ServerView, order []string) []ServerView {
+	pos := make(map[string]int, len(order))
+	for i, name := range order {
+		pos[name] = i
+	}
+	sort.SliceStable(servers, func(i, j int) bool {
+		pi, iok := pos[servers[i].Name]
+		pj, jok := pos[servers[j].Name]
+		switch {
+		case iok && jok:
+			return pi < pj
+		case iok:
+			return true
+		case jok:
+			return false
+		default:
+			return false
+		}
+	})
+	return servers
+}
+
+func mergeServerOrder(order []string, servers []ServerView) []string {
+	seen := make(map[string]bool, len(order)+len(servers))
+	next := make([]string, 0, len(order)+len(servers))
+	for _, name := range order {
+		if name == "" || seen[name] {
+			continue
+		}
+		seen[name] = true
+		next = append(next, name)
+	}
+	for _, s := range servers {
+		if s.Name == "" || seen[s.Name] {
+			continue
+		}
+		seen[s.Name] = true
+		next = append(next, s.Name)
+	}
+	return next
+}
+
+func removeServerOrder(order []string, name string) []string {
+	if name == "" || len(order) == 0 {
+		return order
+	}
+	next := order[:0]
+	for _, n := range order {
+		if n != name {
+			next = append(next, n)
+		}
+	}
+	return next
+}
+
+// ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
+// model") is what SetModel takes; Provider/Model are for display.
+type ModelInfo struct {
+	Ref      string `json:"ref"`
+	Provider string `json:"provider"`
+	Model    string `json:"model"`
+	Current  bool   `json:"current"`
+}
+
+type EffortInfo struct {
+	Supported bool     `json:"supported"`
+	Current   string   `json:"current"`
+	Default   string   `json:"default"`
+	Levels    []string `json:"levels"`
+}
+
+// Models flattens the configured providers into their (provider, model) pairs —
+// the switcher's options — marking the active one. A vendor with a `models` list
+// yields one entry per model, all sharing the same endpoint/key. Unconfigured
+// providers are skipped. Result is non-nil: the frontend reads .length, so a nil
+// slice (JSON null) would crash the switcher on an empty list.
+func (a *App) Models() []ModelInfo {
+	return a.ModelsForTab("")
+}
+
+func (a *App) ModelsForTab(tabID string) []ModelInfo {
+	a.mu.RLock()
+	curModel := ""
+	workspaceRoot := ""
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		curModel = tab.model
+		workspaceRoot = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	cfg, err := config.LoadForRoot(workspaceRoot)
+	if err != nil {
+		return []ModelInfo{}
+	}
+	if entry, ok := cfg.ResolveModel(curModel); ok {
+		curModel = entry.Name + "/" + entry.Model
+	}
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	out := []ModelInfo{}
+	for i := range cfg.Providers {
+		p := &cfg.Providers[i]
+		if !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {
+			continue
+		}
+		for _, m := range p.ChatModelList() {
+			ref := p.Name + "/" + m
+			out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
+		}
+	}
+	return out
+}
+
+func modelProviderAccessAllowed(access map[string]bool, name string) bool {
+	if len(access) == 0 {
+		return true
+	}
+	return access[strings.TrimSpace(name)]
+}
+
+func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
+	if ctrl == nil {
+		return false
+	}
+	status := ctrl.RuntimeStatus()
+	return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
+}
+
+// rebuildBusyError reports a rebuild rejected because the controller still has
+// a running turn, pending prompt, or background jobs. Typed so the
+// deferred-rebuild retry loop can keep waiting instead of giving up.
+type rebuildBusyError struct{ setting string }
+
+func (e *rebuildBusyError) Error() string {
+	return fmt.Sprintf("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s", e.setting)
+}
+
+func rebuildControllerActiveWorkError(setting string) error {
+	return &rebuildBusyError{setting: setting}
+}
+
+type sessionLeaseBusyError struct {
+	setting string
+	err     error
+}
+
+func (e *sessionLeaseBusyError) Error() string {
+	// The raw SessionLeaseError text carries the session path and the
+	// holder's host-pid-writer id; every user-facing surface must render
+	// this wrapper instead. An empty setting means the failure gated opening
+	// the session itself (startup bind), not changing a setting on it.
+	setting := strings.TrimSpace(e.setting)
+	if setting == "" {
+		return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
+	}
+	return fmt.Sprintf("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing %s", setting)
+}
+
+func (e *sessionLeaseBusyError) Unwrap() error {
+	if e == nil {
+		return nil
+	}
+	return e.err
+}
+
+func userFacingSessionLeaseError(setting string, err error) error {
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, agent.ErrSessionLeaseHeld) {
+		return &sessionLeaseBusyError{setting: setting, err: err}
+	}
+	return err
+}
+
+// sessionPathAfterSnapshot returns where a controller rebuild should keep
+// persisting after the old controller was snapshotted. Snapshotting is not
+// path-neutral: a snapshot conflict can recover by retargeting the controller
+// (and the tab's session lease, via handleTabSessionRecovered) to a recovery
+// branch, so a prevPath captured before the snapshot may be stale. Reusing the
+// stale path would bind the rebuilt controller — carrying the just-recovered
+// transcript — back to the original file, turning every later save into a new
+// conflict that derives yet another recovery branch. Falls back to fallback
+// when the controller is gone or persistence is disabled (empty SessionPath).
+func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
+	if ctrl == nil {
+		return fallback
+	}
+	if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
+		return path
+	}
+	return fallback
+}
+
+func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
+	if err := tab.ensureSessionLease(path); err != nil {
+		if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
+			if lease, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
+				tab.adoptSessionLease(lease)
+				return nil
+			} else {
+				err = reclaimErr
+			}
+		}
+		return userFacingSessionLeaseError(setting, err)
+	}
+	return nil
+}
+
+func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path string, err error) bool {
+	key := sessionRuntimeKey(path)
+	if tab == nil || key == "" || !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		return false
+	}
+	var leaseErr *agent.SessionLeaseError
+	if !errors.As(err, &leaseErr) || leaseErr == nil {
+		return false
+	}
+	// A readable info naming a foreign runtime is respected here; reclaim
+	// would refuse it anyway. A nil Info (lease.json deleted by the user,
+	// quarantined by AV, or torn by a crash) must still attempt the reclaim:
+	// the OS lock is the arbiter there, and refusing on missing metadata
+	// wedges a session nobody actually holds as permanently busy.
+	if leaseErr.Info != nil &&
+		(leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
+		return false
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	for _, candidate := range a.runtimeTabsLocked() {
+		if candidate == nil || candidate == tab {
+			continue
+		}
+		if candidate.sessionLeaseRuntimeKey() == key {
+			return false
+		}
+		if candidate.Ctrl != nil && sessionRuntimeKey(candidate.currentSessionPath()) == key {
+			return false
+		}
+	}
+	return true
+}
+
+// SetModel switches the active model and carries the current conversation into the
+// new model's session, so the chat continues seamlessly and subsequent turns use
+// the new model. No-op if name is already active or the controller is down.
+func (a *App) SetModel(name string) error {
+	return a.SetModelForTab("", name)
+}
+
+func (a *App) SetModelForTab(tabID, name string) error {
+	if a.ctx == nil || name == "" {
+		return nil
+	}
+	tab := a.tabByID(tabID)
+	if tab == nil {
+		return nil
+	}
+	a.mu.RLock()
+	currentModel := tab.model
+	a.mu.RUnlock()
+	if name == currentModel {
+		return nil
+	}
+	// Same build+swap shape as rebuildSetting; hold the same lock so a settings
+	// rebuild (manual or from the deferred-rebuild retry loop) and a model
+	// switch cannot interleave on one tab.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	prevPath := a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" {
+		a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
+	}
+	if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+		return rebuildControllerActiveWorkError("model")
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	prevPath = a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
+		prevPath = a.reconciledSessionPathForTab(tab)
+		if prevPath == "" {
+			prevPath = a.currentSessionPathFor(tab)
+		}
+		if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+			return rebuildControllerActiveWorkError("model")
+		}
+	}
+	// Snapshot the tab profile under a.mu: SetModeForTab/SetGoalForTab and the
+	// event sink write these fields under the lock while this rebuild runs
+	// off-lock.
+	snap := a.tabRuntimeSnapshot(tab)
+	cfg, err := config.LoadForRoot(snap.workspaceRoot)
+	if err != nil {
+		return err
+	}
+	entry, ok := cfg.ResolveModel(name)
+	if !ok {
+		return fmt.Errorf("unknown model %q", name)
+	}
+	if !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {
+		return fmt.Errorf("model %q is not available because provider %q is not added", name, entry.Name)
+	}
+	name = entry.Name + "/" + entry.Model
+	effortOverride := cloneStringPtr(snap.effort)
+	if effortOverride != nil {
+		normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))
+		if err != nil {
+			effortOverride = nil
+		} else {
+			effortOverride = &normalized
+		}
+	}
+
+	var carried []provider.Message
+	oldCtrl := a.controllerForTab(tab)
+	if oldCtrl != nil {
+		if prevPath == "" {
+			prevPath = oldCtrl.SessionPath()
+		}
+		if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "model"); err != nil {
+			return err
+		}
+		if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
+			return err
+		}
+		prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
+		carried = oldCtrl.History()
+	}
+
+	// Preserve the shared plugin host across controller rebuilds — the tab
+	// stays in the same workspace root, so MCP processes must not be restarted.
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    name,
+		RequireKey:               false,
+		Sink:                     snap.sink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           cloneStringPtr(effortOverride),
+		TokenMode:                snap.currentTokenMode(),
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		return err
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	newCtrl.SetGoal(snap.goal)
+
+	path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
+	if err := a.ensureTabSessionLeaseForRebuild(tab, path, "model"); err != nil {
+		newCtrl.Close()
+		return err
+	}
+	resumeWithFreshSystemPrompt(newCtrl, carried, path)
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		// The tab was closed/replaced while we built the new controller off-lock;
+		// adopting it now would leak the runtime onto an orphaned tab and pin the
+		// session lease forever.
+		a.mu.Unlock()
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		return fmt.Errorf("tab %q changed while switching model; retry", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.model = name
+	tab.effort = cloneStringPtr(effortOverride)
+	tab.Label = newCtrl.Label()
+	// Supersede any in-flight startup build: it would otherwise finish later,
+	// overwrite this controller, and release/steal the tab's session lease.
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	if oldCtrl != nil {
+		oldCtrl.Close()
+	}
+	// The runtime now reflects the on-disk config; drop any deferred refresh.
+	a.clearDeferredRebuild(tab.ID)
+	a.persistTabSessionPath(tab, path)
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func (a *App) Effort() EffortInfo {
+	return a.EffortForTab("")
+}
+
+func (a *App) EffortForTab(tabID string) EffortInfo {
+	entry, err := a.currentProviderEntryForTab(tabID)
+	if err != nil {
+		return EffortInfo{Current: "auto", Levels: []string{}}
+	}
+	cap := config.EffortCapabilityForEntry(entry)
+	if !cap.Supported {
+		return EffortInfo{Supported: false, Current: "auto", Default: cap.Default, Levels: []string{}}
+	}
+	levels := cap.Levels
+	if levels == nil {
+		levels = []string{}
+	}
+	return EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}
+}
+
+func (a *App) SetEffort(level string) error {
+	return a.SetEffortForTab("", level)
+}
+
+func (a *App) SetEffortForTab(tabID, level string) error {
+	tab := a.tabByID(tabID)
+	if tab == nil {
+		if strings.TrimSpace(tabID) == "" {
+			entry, err := a.currentProviderEntryForTab("")
+			if err != nil {
+				return err
+			}
+			effort, err := config.NormalizeEffort(entry, level)
+			if err != nil {
+				return err
+			}
+			return a.applyProviderEffortConfig(entry, effort)
+		}
+		return fmt.Errorf("tab %q not found", tabID)
+	}
+	// Build+swap path; serialize with the other rebuild paths (see
+	// runtimeRebuildMu). The tab==nil branch above goes through
+	// applyProviderEffortConfig → rebuildSetting, which takes the lock itself.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	prevPath := a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	// Recomputing prevPath after this attach would be a dead store: it is
+	// unconditionally derived again after ensureTabControllerWorkspace below.
+	if a.controllerForTab(tab) == nil && prevPath != "" {
+		a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
+	}
+	if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+		return rebuildControllerActiveWorkError("effort")
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	prevPath = a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
+		prevPath = a.reconciledSessionPathForTab(tab)
+		if prevPath == "" {
+			prevPath = a.currentSessionPathFor(tab)
+		}
+		if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+			return rebuildControllerActiveWorkError("effort")
+		}
+	}
+	snap := a.tabRuntimeSnapshot(tab)
+	entry, err := a.currentProviderEntryForTab(tabID)
+	if err != nil {
+		return err
+	}
+	modelRef := entry.Name + "/" + entry.Model
+	effort, err := config.NormalizeEffort(entry, level)
+	if err != nil {
+		return err
+	}
+	var carried []provider.Message
+	oldCtrl := a.controllerForTab(tab)
+	if oldCtrl != nil {
+		if prevPath == "" {
+			prevPath = oldCtrl.SessionPath()
+		}
+		if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "effort"); err != nil {
+			return err
+		}
+		if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
+			return err
+		}
+		prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
+		carried = oldCtrl.History()
+	}
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    modelRef,
+		RequireKey:               false,
+		Sink:                     snap.sink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           &effort,
+		TokenMode:                snap.currentTokenMode(),
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		return err
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	newCtrl.SetGoal(snap.goal)
+	path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
+	if err := a.ensureTabSessionLeaseForRebuild(tab, path, "effort"); err != nil {
+		newCtrl.Close()
+		return err
+	}
+	resumeWithFreshSystemPrompt(newCtrl, carried, path)
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		a.mu.Unlock()
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		return fmt.Errorf("tab %q changed while switching effort; retry", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.model = modelRef
+	tab.effort = &effort
+	tab.Label = newCtrl.Label()
+	clearTabStartupError(tab)
+	tab.Ready = true
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	if oldCtrl != nil {
+		oldCtrl.Close()
+	}
+	// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
+	a.clearDeferredRebuild(tab.ID)
+	a.persistTabSessionPath(tab, path)
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func (a *App) SetTokenMode(mode string) error {
+	return a.SetTokenModeForTab("", mode)
+}
+
+func (a *App) SetTokenModeForTab(tabID, mode string) error {
+	mode = boot.NormalizeTokenMode(mode)
+	tab := a.tabByID(tabID)
+	if tab == nil {
+		if strings.TrimSpace(tabID) == "" {
+			return nil
+		}
+		return fmt.Errorf("tab %q not found", tabID)
+	}
+	a.mu.RLock()
+	currentMode := boot.NormalizeTokenMode(tab.tokenMode)
+	a.mu.RUnlock()
+	if mode == currentMode {
+		return nil
+	}
+	// Build+swap path; serialize with the other rebuild paths (see runtimeRebuildMu).
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	prevPath := a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	// Recomputing prevPath after this attach would be a dead store: it is
+	// unconditionally derived again after ensureTabControllerWorkspace below.
+	if a.controllerForTab(tab) == nil && prevPath != "" {
+		a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
+	}
+	if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+		return rebuildControllerActiveWorkError("token mode")
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	prevPath = a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
+		prevPath = a.reconciledSessionPathForTab(tab)
+		if prevPath == "" {
+			prevPath = a.currentSessionPathFor(tab)
+		}
+		if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+			return rebuildControllerActiveWorkError("token mode")
+		}
+	}
+	modelRef, fallback, err := a.resolvedModelForTab(tab)
+	if err != nil {
+		return err
+	}
+	snap := a.tabRuntimeSnapshot(tab)
+	if fallback && strings.TrimSpace(snap.model) != "" {
+		a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", snap.model, modelRef))
+	}
+
+	var carried []provider.Message
+	oldCtrl := a.controllerForTab(tab)
+	if oldCtrl != nil {
+		if prevPath == "" {
+			prevPath = oldCtrl.SessionPath()
+		}
+		if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "token mode"); err != nil {
+			return err
+		}
+		if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
+			return err
+		}
+		prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
+		carried = oldCtrl.History()
+	}
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    modelRef,
+		RequireKey:               false,
+		Sink:                     snap.sink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           cloneStringPtr(snap.effort),
+		TokenMode:                mode,
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		return err
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	newCtrl.SetGoal(snap.goal)
+	path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
+	if err := a.ensureTabSessionLeaseForRebuild(tab, path, "token mode"); err != nil {
+		newCtrl.Close()
+		return err
+	}
+	resumeWithFreshSystemPrompt(newCtrl, carried, path)
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		a.mu.Unlock()
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		return fmt.Errorf("tab %q changed while switching token mode; retry", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.model = modelRef
+	tab.tokenMode = mode
+	tab.Label = newCtrl.Label()
+	clearTabStartupError(tab)
+	tab.Ready = true
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	if oldCtrl != nil {
+		oldCtrl.Close()
+	}
+	// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
+	a.clearDeferredRebuild(tab.ID)
+	a.persistTabSessionPath(tab, path)
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {
+	return a.applyConfigChange(func(cfg *config.Config) error {
+		if _, ok := cfg.Provider(entry.Name); !ok {
+			if err := cfg.UpsertProvider(*entry); err != nil {
+				return err
+			}
+		}
+		if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" {
+			if err := cfg.SetProviderThinking(entry.Name, "adaptive"); err != nil {
+				return err
+			}
+		}
+		for _, name := range providerEffortTargetNames(cfg, entry) {
+			if err := cfg.SetProviderEffort(name, effort); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
+	if cfg == nil || entry == nil {
+		return nil
+	}
+	out := []string{entry.Name}
+	seen := map[string]bool{entry.Name: true}
+	kind := officialProviderKindFromEntry(*entry)
+	if kind == "" {
+		return out
+	}
+	var family []string
+	switch kind {
+	case "deepseek":
+		family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
+	}
+	for _, name := range family {
+		if seen[name] {
+			continue
+		}
+		p, ok := cfg.Provider(name)
+		if !ok || officialProviderKindFromEntry(*p) != kind {
+			continue
+		}
+		seen[name] = true
+		out = append(out, name)
+	}
+	return out
+}
+
+// DirEntry is one entry in the "@" file-reference menu.
+type DirEntry struct {
+	Name        string `json:"name"`
+	Path        string `json:"path,omitempty"`
+	IsDir       bool   `json:"isDir"`
+	DisplayName string `json:"displayName,omitempty"`
+	DisplayPath string `json:"displayPath,omitempty"`
+}
+
+// FilePreview is a bounded, read-only file payload for the workspace side panel.
+type FilePreview struct {
+	Path      string `json:"path"`
+	Body      string `json:"body"`
+	Size      int64  `json:"size"`
+	Truncated bool   `json:"truncated"`
+	Binary    bool   `json:"binary"`
+	Kind      string `json:"kind,omitempty"`
+	Mime      string `json:"mime,omitempty"`
+	URL       string `json:"url,omitempty"`
+	Err       string `json:"err,omitempty"`
+}
+
+type WorkspaceChangeView struct {
+	Path         string   `json:"path"`
+	OldPath      string   `json:"oldPath,omitempty"`
+	Sources      []string `json:"sources"`
+	GitStatus    string   `json:"gitStatus,omitempty"`
+	Turns        []int    `json:"turns,omitempty"`
+	LatestPrompt string   `json:"latestPrompt,omitempty"`
+	LatestTime   int64    `json:"latestTime,omitempty"`
+}
+
+type WorkspaceChangesView struct {
+	Files        []WorkspaceChangeView `json:"files"`
+	GitAvailable bool                  `json:"gitAvailable"`
+	GitErr       string                `json:"gitErr,omitempty"`
+	GitBranch    string                `json:"gitBranch,omitempty"`
+}
+
+// workspaceNoiseNames are local cache/vendor entries hidden from the file tree
+// and "@" menu regardless of where they appear.
+var workspaceNoiseNames = map[string]bool{
+	".codex":       true,
+	".DS_Store":    true,
+	".git":         true,
+	".npm":         true,
+	".pnpm-store":  true,
+	"node_modules": true,
+	"Thumbs.db":    true,
+}
+
+var workspaceNoiseDirs = map[string]bool{
+	"bin":                      true,
+	"desktop/build":            true,
+	"desktop/frontend/dist":    true,
+	"desktop/frontend/wailsjs": true,
+	"dist":                     true,
+	"npm/.stage":               true,
+	"site/.astro":              true,
+	"site/dist":                true,
+	"stage":                    true,
+	"tmp":                      true,
+}
+
+const filePreviewLimit = 256 * 1024
+const fileRefSearchLimit = 20
+
+var previewMediaMIMEs = map[string]string{
+	".bmp":  "image/bmp",
+	".gif":  "image/gif",
+	".jpeg": "image/jpeg",
+	".jpg":  "image/jpeg",
+	".pdf":  "application/pdf",
+	".png":  "image/png",
+	".svg":  "image/svg+xml",
+	".webp": "image/webp",
+}
+
+func trimUTF8PartialSuffix(data []byte) []byte {
+	if utf8.Valid(data) {
+		return data
+	}
+	for i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {
+		if !utf8.RuneStart(data[i]) {
+			continue
+		}
+		if !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {
+			return data
+		}
+		return data[:i]
+	}
+	return data
+}
+
+func previewMediaKind(path string) (kind string, mime string) {
+	mime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]
+	if mime == "" {
+		return "", ""
+	}
+	if strings.HasPrefix(mime, "image/") {
+		return "image", mime
+	}
+	if mime == "application/pdf" {
+		return "pdf", mime
+	}
+	return "", ""
+}
+
+func workspaceEntryRel(rel, name string) string {
+	rel = strings.Trim(filepath.ToSlash(rel), "/")
+	if rel == "" || rel == "." {
+		return name
+	}
+	return rel + "/" + name
+}
+
+func skipWorkspaceEntry(rel, name string, isDir bool) bool {
+	if workspaceNoiseNames[name] {
+		return true
+	}
+	return isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]
+}
+
+func (a *App) activeWorkspaceBase() (string, error) {
+	return workspaceBaseFromRoot(a.activeWorkspaceRoot())
+}
+
+func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
+	tabID = strings.TrimSpace(tabID)
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		if tabID == "" {
+			return ".", nil, true
+		}
+		return "", nil, false
+	}
+	return tab.WorkspaceRoot, tab.Ctrl, true
+}
+
+func workspaceBaseFromRoot(root string) (string, error) {
+	if strings.TrimSpace(root) == "" || root == "." {
+		return os.Getwd()
+	}
+	if abs, err := filepath.Abs(root); err == nil {
+		root = abs
+	}
+	return filepath.Clean(root), nil
+}
+
+func workspacePathForBase(base, rel string) (string, bool, error) {
+	base = filepath.Clean(base)
+	if rel == "" {
+		return "", false, os.ErrInvalid
+	}
+	path := rel
+	if !filepath.IsAbs(path) {
+		path = filepath.Join(base, rel)
+	}
+	path = filepath.Clean(path)
+	r, err := filepath.Rel(base, path)
+	if err != nil {
+		return "", false, err
+	}
+	if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) {
+		return "", false, os.ErrPermission
+	}
+	return path, true, nil
+}
+
+// ListDir lists one directory level (directories first, then files, each
+// alphabetical) for the "@" file-reference menu. rel resolves against the active
+// tab workspace. The menu navigates one level at a time, never recursively —
+// bounded for huge trees.
+func (a *App) ListDir(rel string) []DirEntry {
+	return a.ListDirForTab("", rel)
+}
+
+// ListDirForTab is the tab-scoped variant used by multi-tab frontend surfaces.
+func (a *App) ListDirForTab(tabID, rel string) []DirEntry {
+	root, ctrl, ok := a.workspaceTargetForTab(tabID)
+	if !ok {
+		return []DirEntry{}
+	}
+	if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
+		if entries, handled := browser.ListExternalFolderRefDir(rel); handled {
+			return externalFolderDirEntries(entries)
+		}
+	}
+	base, err := workspaceBaseFromRoot(root)
+	if err != nil {
+		return []DirEntry{}
+	}
+	dir := base
+	if rel != "" {
+		path, ok, err := workspacePathForBase(base, rel)
+		if err != nil || !ok {
+			return []DirEntry{}
+		}
+		dir = path
+	}
+	es, err := os.ReadDir(dir)
+	if err != nil {
+		return []DirEntry{}
+	}
+	dirs, files := []DirEntry{}, []DirEntry{}
+	for _, e := range es {
+		name := e.Name()
+		if skipWorkspaceEntry(rel, name, e.IsDir()) {
+			continue
+		}
+		if e.IsDir() {
+			dirs = append(dirs, DirEntry{Name: name, IsDir: true})
+			continue
+		}
+		info, err := e.Info()
+		if err != nil || !info.Mode().IsRegular() {
+			continue
+		}
+		files = append(files, DirEntry{Name: name, IsDir: false})
+	}
+	sort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })
+	sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })
+	return append(dirs, files...)
+}
+
+// SearchFileRefs finds workspace files by basename for bare "@token" completion.
+func (a *App) SearchFileRefs(query string) []DirEntry {
+	return a.SearchFileRefsForTab("", query)
+}
+
+// SearchFileRefsForTab is the tab-scoped variant used by multi-tab frontend surfaces.
+func (a *App) SearchFileRefsForTab(tabID, query string) []DirEntry {
+	root, ctrl, ok := a.workspaceTargetForTab(tabID)
+	if !ok {
+		return []DirEntry{}
+	}
+	base, err := workspaceBaseFromRoot(root)
+	if err != nil {
+		return []DirEntry{}
+	}
+	results := fileref.Search(base, query, fileRefSearchLimit)
+	out := make([]DirEntry, 0, len(results))
+	for _, r := range results {
+		out = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})
+	}
+	if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
+		out = append(out, externalFolderDirEntries(browser.SearchExternalFolderRefs(query, fileRefSearchLimit))...)
+	}
+	return out
+}
+
+type externalFolderRefBrowser interface {
+	ListExternalFolderRefDir(tokenPath string) ([]control.ExternalFolderRefEntry, bool)
+	SearchExternalFolderRefs(query string, limit int) []control.ExternalFolderRefEntry
+	ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool)
+}
+
+func externalFolderRefBrowserFromController(ctrl control.SessionAPI) externalFolderRefBrowser {
+	if browser, ok := ctrl.(externalFolderRefBrowser); ok {
+		return browser
+	}
+	return nil
+}
+
+func externalFolderDirEntries(entries []control.ExternalFolderRefEntry) []DirEntry {
+	out := make([]DirEntry, 0, len(entries))
+	for _, e := range entries {
+		out = append(out, DirEntry{
+			Name:        e.Name,
+			Path:        e.Path,
+			IsDir:       e.IsDir,
+			DisplayName: e.DisplayName,
+			DisplayPath: e.DisplayPath,
+		})
+	}
+	return out
+}
+
+func (a *App) workspaceOrExternalPathForTab(tabID, rel string) (string, bool, error) {
+	root, ctrl, ok := a.workspaceTargetForTab(tabID)
+	if !ok {
+		return "", false, os.ErrNotExist
+	}
+	if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
+		if path, _, ok := browser.ExternalFolderRefLocalPath(rel); ok {
+			return path, true, nil
+		}
+	}
+	base, err := workspaceBaseFromRoot(root)
+	if err != nil {
+		return "", false, err
+	}
+	return workspacePathForBase(base, rel)
+}
+
+// ReadFile returns a small text preview for a file under the current workspace
+// or a session-authorized external folder ref.
+func (a *App) ReadFile(rel string) FilePreview {
+	return a.ReadFileForTab("", rel)
+}
+
+// ReadFileForTab returns a preview resolved against the requested tab.
+func (a *App) ReadFileForTab(tabID, rel string) FilePreview {
+	out := FilePreview{Path: rel}
+	path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
+	if err != nil || !ok {
+		out.Err = "invalid path"
+		return out
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		out.Err = err.Error()
+		return out
+	}
+	if info.IsDir() {
+		out.Err = "path is a directory"
+		return out
+	}
+	if !info.Mode().IsRegular() {
+		out.Err = "path is not a regular file"
+		return out
+	}
+	out.Size = info.Size()
+	if kind, mime := previewMediaKind(path); kind != "" {
+		token := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())
+		out.Kind = kind
+		out.Mime = mime
+		out.URL = "/__reasonix_workspace_media/" + token + "/" + url.PathEscape(info.Name())
+		return out
+	}
+	f, err := os.Open(path)
+	if err != nil {
+		out.Err = err.Error()
+		return out
+	}
+	defer f.Close()
+
+	buf := make([]byte, filePreviewLimit+1)
+	n, err := f.Read(buf)
+	if err != nil && err != io.EOF {
+		out.Err = err.Error()
+		return out
+	}
+	data := buf[:n]
+	if len(data) > filePreviewLimit {
+		data = data[:filePreviewLimit]
+		out.Truncated = true
+	}
+
+	// Check for BOM first (just the first 2-3 bytes — always complete
+	// even at a truncation boundary). BOM-prefixed files skip the NUL
+	// check since UTF-16 normally contains 0x00 for ASCII characters.
+	bomKind := fileenc.DetectQuick(data)
+	if bomKind != fileenc.UTF8 {
+		enc, _ := fileenc.Detect(data)
+		if enc == fileenc.LossyUTF8 {
+			out.Binary = true
+			return out
+		}
+		decoded := fileenc.Decode(data, enc)
+		out.Body = string(decoded)
+		return out
+	}
+
+	// No BOM — NUL in raw bytes is a binary signal.
+	if bytes.Contains(data, []byte{0}) {
+		out.Binary = true
+		return out
+	}
+
+	// Trim any partial multi-byte rune at the truncation boundary BEFORE
+	// encoding detection. Without this, a large UTF-8 file truncated
+	// mid-character would fail utf8.Valid and be misdetected as GB18030
+	// or LossyUTF8, producing mojibake or a false binary classification.
+	if out.Truncated {
+		data = trimUTF8PartialSuffix(data)
+	}
+	enc, _ := fileenc.Detect(data)
+	if enc == fileenc.LossyUTF8 {
+		out.Binary = true
+		return out
+	}
+	out.Body = string(fileenc.Decode(data, enc))
+	return out
+}
+
+// OpenWorkspacePath opens a workspace or authorized external-ref file/folder in
+// the OS default app.
+func (a *App) OpenWorkspacePath(rel string) error {
+	return a.OpenWorkspacePathForTab("", rel)
+}
+
+// OpenWorkspacePathForTab opens a path resolved against the requested tab.
+func (a *App) OpenWorkspacePathForTab(tabID, rel string) error {
+	path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
+	if err != nil || !ok {
+		return os.ErrInvalid
+	}
+	return openWorkspacePath(path)
+}
+
+// RevealWorkspacePath shows a workspace or authorized external-ref file in the
+// native file manager.
+func (a *App) RevealWorkspacePath(rel string) error {
+	return a.RevealWorkspacePathForTab("", rel)
+}
+
+// RevealWorkspacePathForTab reveals a path resolved against the requested tab.
+func (a *App) RevealWorkspacePathForTab(tabID, rel string) error {
+	path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
+	if err != nil || !ok {
+		return os.ErrInvalid
+	}
+	return revealPath(path)
+}
+
+// RevealPath shows an arbitrary absolute path in the native file manager.
+func (a *App) RevealPath(path string) error {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return os.ErrInvalid
+	}
+	if abs, err := filepath.Abs(path); err == nil {
+		path = abs
+	}
+	return revealPath(path)
+}
+
+var revealPath = defaultRevealPath
+
+func defaultRevealPath(path string) error {
+	switch goruntime.GOOS {
+	case "darwin":
+		return exec.Command("open", "-R", path).Start()
+	case "windows":
+		// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the
+		// launch environment can strip it), so resolve it directly rather than
+		// relying on a PATH lookup.
+		explorer := "explorer.exe"
+		root := os.Getenv("SystemRoot")
+		if root == "" {
+			root = os.Getenv("windir")
+		}
+		if root != "" {
+			explorer = filepath.Join(root, "explorer.exe")
+		}
+		return exec.Command(explorer, "/select,", path).Start()
+	default:
+		dir := path
+		if info, err := os.Stat(path); err == nil && !info.IsDir() {
+			dir = filepath.Dir(path)
+		}
+		return exec.Command("xdg-open", dir).Start()
+	}
+}
+
+func (a *App) noticeForTab(tabID, text string) {
+	tab := a.tabByID(tabID)
+	if tab != nil && tab.sink != nil {
+		tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
+	}
+}
+
+func (a *App) warnForTab(tabID, text string) {
+	tab := a.tabByID(tabID)
+	if tab != nil && tab.sink != nil {
+		tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
+	}
+}
+
+func (a *App) runEffortCommandForTab(tabID, input string) {
+	entry, err := a.currentProviderEntryForTab(tabID)
+	if err != nil {
+		a.noticeForTab(tabID, "effort: "+err.Error())
+		return
+	}
+	cap := config.EffortCapabilityForEntry(entry)
+	if !cap.Supported {
+		a.noticeForTab(tabID, fmt.Sprintf("effort is not configurable for %s", entry.Name))
+		return
+	}
+	args := strings.Fields(input)
+	if len(args) < 2 {
+		a.noticeForTab(tabID, fmt.Sprintf("effort for %s: %s (default: %s; options: %s)", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, "|")))
+		return
+	}
+	if len(args) > 2 {
+		a.noticeForTab(tabID, "usage: /effort "+strings.Join(cap.Levels, "|"))
+		return
+	}
+	effort, err := config.NormalizeEffort(entry, args[1])
+	if err != nil {
+		a.noticeForTab(tabID, err.Error())
+		return
+	}
+	if err := a.SetEffortForTab(tabID, args[1]); err != nil {
+		a.noticeForTab(tabID, "effort: "+err.Error())
+		return
+	}
+	display := effort
+	if display == "" {
+		display = "auto"
+	}
+	a.noticeForTab(tabID, fmt.Sprintf("effort for %s set to %s", entry.Name, display))
+}
+
+func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {
+	if tab := a.tabByID(tabID); tab != nil {
+		a.reconcileTabWithPinnedSessionMeta(tab)
+	}
+	a.mu.RLock()
+	ref := ""
+	workspaceRoot := ""
+	effortOverride := (*string)(nil)
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		ref = tab.model
+		workspaceRoot = tab.WorkspaceRoot
+		effortOverride = cloneStringPtr(tab.effort)
+	}
+	a.mu.RUnlock()
+	cfg, err := config.LoadForRoot(workspaceRoot)
+	if err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(ref) == "" {
+		ref = cfg.DefaultModel
+	}
+	config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
+	resolved, _, ok := cfg.ResolveModelWithFallback(ref)
+	if !ok {
+		return nil, fmt.Errorf("unknown model %q", ref)
+	}
+	entry, ok := cfg.ResolveModel(resolved)
+	if !ok {
+		return nil, fmt.Errorf("unknown model %q", resolved)
+	}
+	if effortOverride != nil {
+		entry.Effort = *effortOverride
+	}
+	return entry, nil
+}
+
+func (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {
+	if tab == nil {
+		return "", false, fmt.Errorf("no active tab")
+	}
+	cfg, err := config.LoadForRoot(tab.WorkspaceRoot)
+	if err != nil {
+		return "", false, err
+	}
+	ref := strings.TrimSpace(tab.model)
+	if ref == "" {
+		ref = cfg.DefaultModel
+	}
+	config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
+	resolved, fallback, ok := cfg.ResolveModelWithFallback(ref)
+	if !ok {
+		return "", false, fmt.Errorf("unknown model %q", ref)
+	}
+	return resolved, fallback, nil
+}
+
+func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {
+	var result string
+	err := a.withActiveWorkspaceDo(func() error {
+		var err error
+		result, err = fn()
+		return err
+	})
+	return result, err
+}
+
+func (a *App) withActiveWorkspaceDo(fn func() error) error {
+	root := a.activeWorkspaceRoot()
+	if root != "" && root != "." {
+		prev, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		if err := os.Chdir(root); err != nil {
+			return err
+		}
+		defer func() { _ = os.Chdir(prev) }()
+	}
+	return fn()
+}
+
+// SavePastedImage stores a browser clipboard image data URL under the active
+// tab's workspace .reasonix/attachments and returns the relative @-reference path.
+func (a *App) SavePastedImage(dataURL string) (string, error) {
+	return a.withActiveWorkspace(func() (string, error) {
+		return control.SaveImageDataURL(dataURL)
+	})
+}
+
+// SaveClipboardImage reads the native OS clipboard image under the active tab's
+// workspace .reasonix/attachments and returns the relative @-reference path.
+func (a *App) SaveClipboardImage() (string, error) {
+	return a.withActiveWorkspace(control.SaveClipboardImage)
+}
+
+// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
+// as a data URL but not a real path) under the active tab's workspace
+// .reasonix/attachments and returns the relative @-reference path.
+func (a *App) SavePastedFile(name, dataURL string) (string, error) {
+	return a.withActiveWorkspace(func() (string, error) {
+		return control.SaveAttachmentDataURL(name, dataURL)
+	})
+}
+
+// PickExportFile opens the native save dialog and returns the selected path. It
+// returns "" when the user cancels.
+func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	defaultFilename = safeExportFilename(defaultFilename)
+	ext := strings.ToLower(filepath.Ext(defaultFilename))
+	path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
+		Title:                "Export session",
+		DefaultDirectory:     dialogDefaultDirectory(a.activeWorkspaceRoot()),
+		DefaultFilename:      defaultFilename,
+		CanCreateDirectories: true,
+		Filters:              exportFileFilters(mimeType, ext),
+	})
+	if err != nil || path == "" {
+		return "", err
+	}
+	if ext != "" && filepath.Ext(path) == "" {
+		path += ext
+	}
+	return path, nil
+}
+
+// SaveExportFile writes an exported session payload to a path previously picked
+// by PickExportFile. An empty path is treated as a cancelled export.
+func (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {
+	if strings.TrimSpace(path) == "" {
+		return nil
+	}
+	var data []byte
+	var err error
+	if base64Encoded {
+		data, err = base64.StdEncoding.DecodeString(payload)
+		if err != nil {
+			return fmt.Errorf("decode export payload: %w", err)
+		}
+	} else {
+		data = []byte(payload)
+	}
+	if err := os.WriteFile(path, data, 0o644); err != nil {
+		return err
+	}
+	return nil
+}
+
+func safeExportFilename(name string) string {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return "reasonix-session.md"
+	}
+	return filepath.Base(name)
+}
+
+func exportFileFilters(mimeType, ext string) []runtime.FileFilter {
+	switch mimeType {
+	case "text/markdown":
+		return []runtime.FileFilter{{DisplayName: "Markdown (*.md)", Pattern: "*.md"}}
+	case "application/json":
+		return []runtime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}
+	case "application/pdf":
+		return []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}
+	case "image/png":
+		return []runtime.FileFilter{{DisplayName: "PNG image (*.png)", Pattern: "*.png"}}
+	}
+	if ext != "" {
+		return []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, ".")) + " files (*" + ext + ")", Pattern: "*" + ext}}
+	}
+	return []runtime.FileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}}
+}
+
+// AttachmentDataURL returns a safe data URL for a stored image attachment.
+func (a *App) AttachmentDataURL(path string) (string, error) {
+	return a.withActiveWorkspace(func() (string, error) {
+		return control.ImageDataURL(path)
+	})
+}
+
+// DroppedItem is one OS-dropped file resolved into a composer context entry: an
+// in-tree file becomes a workspace @reference (read in place, no copy), while an
+// outside directory becomes a session-scoped workspace @reference; an image or
+// out-of-tree file is copied into .reasonix/attachments.
+type DroppedItem struct {
+	Kind        string `json:"kind"` // "workspace" | "attachment"
+	Path        string `json:"path"`
+	IsDir       bool   `json:"isDir,omitempty"`
+	DisplayPath string `json:"displayPath,omitempty"`
+	PreviewURL  string `json:"previewUrl,omitempty"`
+}
+
+// AttachDropped turns an absolute path from the native file-drop bridge into a
+// composer context entry. Images are stored as attachments so the chip shows a
+// thumbnail; in-workspace files are referenced relatively (no copy); directories
+// outside the workspace are registered as current-session folder references;
+// files outside the workspace are copied into .reasonix/attachments.
+func (a *App) AttachDropped(path string) (DroppedItem, error) {
+	var item DroppedItem
+	err := a.withActiveWorkspaceDo(func() error {
+		info, err := os.Lstat(path)
+		if err != nil {
+			return err
+		}
+		if isImageExt(path) {
+			if rel, err := control.SaveImageFile(path); err == nil {
+				preview, _ := control.ImageDataURL(rel)
+				item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}
+				return nil
+			}
+		}
+		if rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {
+			item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}
+			return nil
+		}
+		if info.IsDir() {
+			tab, ctrl := a.tabAndCtrlByID("")
+			if err := a.ensureTabControllerWorkspace(tab); err != nil {
+				return err
+			}
+			if tab != nil {
+				ctrl = a.controllerForTab(tab)
+			}
+			if ctrl == nil {
+				return fmt.Errorf("workspace is not ready")
+			}
+			token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
+			if err != nil {
+				return err
+			}
+			item = DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}
+			return nil
+		}
+		rel, err := control.SaveAttachmentFile(path)
+		if err != nil {
+			return err
+		}
+		item = DroppedItem{Kind: "attachment", Path: rel}
+		return nil
+	})
+	if err != nil {
+		return DroppedItem{}, err
+	}
+	return item, nil
+}
+
+func isImageExt(path string) bool {
+	switch strings.ToLower(filepath.Ext(path)) {
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp":
+		return true
+	}
+	return false
+}
+
+func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
+	root := workspaceRoot
+	if !filepath.IsAbs(root) {
+		abs, err := filepath.Abs(root)
+		if err != nil {
+			return "", false
+		}
+		root = abs
+	}
+	rel, err := filepath.Rel(root, path)
+	if err != nil {
+		return "", false
+	}
+	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
+		return "", false
+	}
+	return filepath.ToSlash(rel), true
+}
+
+// --- memory panel (frontend ⇄ controller) ---
+
+// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.
+type MemoryDoc struct {
+	Path  string `json:"path"`
+	Scope string `json:"scope"`
+	Body  string `json:"body"`
+}
+
+// MemoryFact is one saved auto-memory, surfaced read-only in the panel.
+type MemoryFact struct {
+	Name        string `json:"name"`
+	Title       string `json:"title,omitempty"`
+	Description string `json:"description"`
+	Type        string `json:"type"`
+	Body        string `json:"body"`
+}
+
+// MemoryArchive is one archived auto-memory kept only for inspection.
+type MemoryArchive struct {
+	Name        string `json:"name"`
+	Title       string `json:"title,omitempty"`
+	Description string `json:"description"`
+	Type        string `json:"type"`
+	Body        string `json:"body"`
+	Path        string `json:"path"`
+	ArchivedAt  string `json:"archivedAt,omitempty"`
+}
+
+// MemoryScope is one writable quick-add target (scope id + the file it writes to).
+type MemoryScope struct {
+	Scope string `json:"scope"`
+	Path  string `json:"path"`
+}
+
+// MemoryView is the whole memory panel payload: hierarchical docs, active saved
+// facts, archived facts, and the writable scopes for the quick-add selector.
+type MemoryView struct {
+	Docs           []MemoryDoc     `json:"docs"`
+	Facts          []MemoryFact    `json:"facts"`
+	Archives       []MemoryArchive `json:"archives"`
+	Scopes         []MemoryScope   `json:"scopes"`
+	StoreDir       string          `json:"storeDir"`
+	StoreGlobalDir string          `json:"storeGlobalDir,omitempty"`
+	Available      bool            `json:"available"`
+}
+
+// writableScopes are the quick-add targets the panel offers, broad → specific.
+var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}
+
+// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,
+// active/archived auto-memories, and the writable scopes. Read-only; mutations
+// go through Remember / SaveDoc.
+func (a *App) Memory() MemoryView {
+	return a.memoryForCtrl(nil, true)
+}
+
+// MemoryForTab returns the loaded memory for a specific tab's controller,
+// so the panel can show memory for any open project, not just the active tab.
+// If the tab does not exist or has no controller, returns an empty view
+// instead of falling back to the active tab (which would show the wrong data).
+// An empty tabID is treated as "no tab specified" and falls back to the
+// active tab for backward compatibility.
+func (a *App) MemoryForTab(tabID string) MemoryView {
+	if tabID == "" {
+		return a.memoryForCtrl(nil, true)
+	}
+	return a.memoryForCtrl(a.ctrlByTabID(tabID), false)
+}
+
+func (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {
+	view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}
+	if ctrl == nil {
+		if !fallback {
+			return view
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return view
+		}
+	}
+	set := ctrl.Memory()
+	if set == nil {
+		return view
+	}
+	view.StoreDir = set.Store.Dir
+	view.StoreGlobalDir = set.Store.GlobalDir
+	view.Available = true
+	for _, d := range set.Docs {
+		view.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})
+	}
+	for _, f := range set.Store.List() {
+		view.Facts = append(view.Facts, MemoryFact{
+			Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,
+		})
+	}
+	for _, f := range set.Store.ListArchived() {
+		archivedAt := ""
+		if !f.ArchivedAt.IsZero() {
+			archivedAt = f.ArchivedAt.Format(time.RFC3339)
+		}
+		view.Archives = append(view.Archives, MemoryArchive{
+			Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,
+			Path: f.Path, ArchivedAt: archivedAt,
+		})
+	}
+	for _, sc := range writableScopes {
+		if p := set.DocPath(sc); p != "" {
+			view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})
+		}
+	}
+	return view
+}
+
+// Remember quick-adds a one-line note to the doc-memory file for scope — the
+// panel's explicit "remember" action, equivalent to typing "/remember ".
+// An unknown scope falls back to project. Returns the file written.
+func (a *App) Remember(scope, note string) (string, error) {
+	return a.rememberForCtrl(nil, scope, note, true)
+}
+
+func (a *App) RememberForTab(tabID, scope, note string) (string, error) {
+	if tabID == "" {
+		return a.rememberForCtrl(nil, scope, note, true)
+	}
+	return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)
+}
+
+func (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {
+	if ctrl == nil {
+		if !fallback {
+			return "", nil
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return "", nil
+		}
+	}
+	return ctrl.QuickAdd(parseScope(scope), note)
+}
+
+// Forget deletes a saved auto-memory by name — the panel's delete action for a
+// fact the model owns. A no-op when no controller is attached.
+func (a *App) Forget(name string) error {
+	return a.forgetForCtrl(nil, name, true)
+}
+
+func (a *App) ForgetForTab(tabID, name string) error {
+	if tabID == "" {
+		return a.forgetForCtrl(nil, name, true)
+	}
+	return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)
+}
+
+func (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {
+	if ctrl == nil {
+		if !fallback {
+			return nil
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return nil
+		}
+	}
+	return ctrl.ForgetMemory(name)
+}
+
+// SaveDoc overwrites a memory doc with the panel editor's contents. The controller
+// validates path against the recognized memory files. Returns the file written.
+func (a *App) SaveDoc(path, body string) (string, error) {
+	return a.saveDocForCtrl(nil, path, body, true)
+}
+
+func (a *App) SaveDocForTab(tabID, path, body string) (string, error) {
+	if tabID == "" {
+		return a.saveDocForCtrl(nil, path, body, true)
+	}
+	return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)
+}
+
+func (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {
+	if ctrl == nil {
+		if !fallback {
+			return "", nil
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return "", nil
+		}
+	}
+	return ctrl.SaveDoc(path, body)
+}
+
+// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.
+func parseScope(s string) memory.Scope {
+	switch memory.Scope(s) {
+	case memory.ScopeUser:
+		return memory.ScopeUser
+	case memory.ScopeLocal:
+		return memory.ScopeLocal
+	default:
+		return memory.ScopeProject
+	}
+}
+
+// onboardingKeyEnv is the default provider (deepseek) key from config.Default().
+const onboardingKeyEnv = "DEEPSEEK_API_KEY"
+
+// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:
+// billing.FetchWithClient surfaces 401/403 for a bad key.
+const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
+
+var connectKeyBalanceFetch = billing.FetchWithClient
+
+// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
+// dialog that replaces web-style confirm() for destructive or important actions.
+type NativeConfirmRequest struct {
+	Title        string `json:"title"`
+	Message      string `json:"message"`
+	Detail       string `json:"detail"`
+	ConfirmLabel string `json:"confirmLabel"`
+	CancelLabel  string `json:"cancelLabel"`
+	Destructive  bool   `json:"destructive"`
+}
+
+// ConfirmAction shows a native confirmation dialog and returns true when the user
+// clicks the confirm button. For destructive actions the dialog type is Warning so
+// the platform can apply its danger styling (red tint on macOS, etc.).
+func (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {
+	if a.ctx == nil {
+		return false, nil
+	}
+	dialogType := runtime.QuestionDialog
+	if req.Destructive {
+		dialogType = runtime.WarningDialog
+	}
+	confirm := req.ConfirmLabel
+	if confirm == "" {
+		confirm = "OK"
+	}
+	cancel := req.CancelLabel
+	if cancel == "" {
+		cancel = "Cancel"
+	}
+	title := req.Title
+	if title == "" {
+		title = req.Message
+	}
+	body := req.Message
+	if req.Detail != "" {
+		if body != "" {
+			body += "\n\n" + req.Detail
+		} else {
+			body = req.Detail
+		}
+	}
+	defaultBtn := confirm
+	if req.Destructive {
+		// On destructive actions, make cancel the default so Enter / Space
+		// does NOT accidentally confirm. ESC always maps to CancelButton.
+		defaultBtn = cancel
+	}
+	result, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
+		Type:          dialogType,
+		Title:         title,
+		Message:       body,
+		Buttons:       []string{confirm, cancel},
+		DefaultButton: defaultBtn,
+		CancelButton:  cancel,
+	})
+	if err != nil {
+		return false, err
+	}
+	return result == confirm, nil
+}
+
+func (a *App) NeedsOnboarding() bool {
+	return !config.CredentialStored(onboardingKeyEnv)
+}
+
+// ConnectKey validates apiKey against the balance endpoint, persists it to
+// Reasonix's global .env, and rebuilds the controller so the new key takes effect.
+func (a *App) ConnectKey(apiKey string) (string, error) {
+	apiKey = strings.TrimSpace(apiKey)
+	if apiKey == "" {
+		return "", fmt.Errorf("key is required")
+	}
+	if tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {
+		return "", rebuildControllerActiveWorkError("provider key")
+	}
+	ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
+	defer cancel()
+	if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
+		return "", fmt.Errorf("validate: %w", err)
+	}
+	warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
+	if err != nil {
+		return "", fmt.Errorf("save: %w", err)
+	}
+	if err := a.rebuildSetting("provider key"); err != nil {
+		if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok {
+			warning = appendSettingsWarning(warning, rebuildWarning)
+		} else {
+			return "", err
+		}
+	}
+	return warning, nil
+}
diff --git a/desktop/app_autosave_test.go b/desktop/app_autosave_test.go
new file mode 100644
index 0000000..d4792ad
--- /dev/null
+++ b/desktop/app_autosave_test.go
@@ -0,0 +1,660 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/provider"
+	"reasonix/internal/tool"
+)
+
+type stubProvider struct{}
+
+func (stubProvider) Name() string { return "stub" }
+
+func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
+	ch := make(chan provider.Chunk, 1)
+	close(ch)
+	return ch, nil
+}
+
+func controllerWithContent(t *testing.T, path string) *control.Controller {
+	t.Helper()
+	sess := agent.NewSession("system")
+	sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
+	sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
+}
+
+func waitForFile(t *testing.T, path, want string) {
+	t.Helper()
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
+			return
+		}
+		time.Sleep(5 * time.Millisecond)
+	}
+	t.Fatalf("session file %q never contained %q", path, want)
+}
+
+func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
+	t.Helper()
+	waitForAutosaveIdleWithin(t, tab, 2*time.Second)
+}
+
+func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
+	t.Helper()
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		tab.saveMu.Lock()
+		idle := !tab.saving && !tab.saveAgain
+		tab.saveMu.Unlock()
+		if idle {
+			return
+		}
+		time.Sleep(5 * time.Millisecond)
+	}
+	t.Fatal("autosave loop did not become idle")
+}
+
+func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
+	t.Helper()
+	ctrl := controllerWithContent(t, path)
+	tab := &WorkspaceTab{
+		ID:            "test_tab",
+		Ctrl:          ctrl,
+		Scope:         "global",
+		WorkspaceRoot: "",
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
+	a := &App{
+		tabs:        map[string]*WorkspaceTab{"test_tab": tab},
+		activeTabID: "test_tab",
+	}
+	tab.sink.app = a
+	return a, tab
+}
+
+// TestTurnDonePersistsSession proves a completed turn is written to disk without
+// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
+// nil sink ctx (no webview) must not disable persistence.
+func TestTurnDonePersistsSession(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+	_, tab := appWithTab(t, path)
+
+	tab.sink.Emit(event.Event{Kind: event.TurnDone})
+
+	waitForFile(t, path, "remember this turn")
+	waitForAutosaveIdle(t, tab)
+}
+
+// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
+// per-token event storm doesn't thrash the disk.
+func TestNonTurnDoneDoesNotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+	a, tab := appWithTab(t, path)
+	_ = a
+
+	tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
+
+	time.Sleep(50 * time.Millisecond)
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
+	}
+}
+
+// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
+// single-flight loop neither panics nor drops the final write.
+func TestScheduleSnapshotCoalesces(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+	a, tab := appWithTab(t, path)
+	_ = a
+
+	var wg sync.WaitGroup
+	for i := 0; i < 64; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			tab.sink.Emit(event.Event{Kind: event.TurnDone})
+		}()
+	}
+	wg.Wait()
+
+	waitForFile(t, path, "acknowledged")
+	waitForAutosaveIdle(t, tab)
+}
+
+func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, tab := appWithTab(t, path)
+	_ = a
+
+	tab.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForAutosaveIdleWithin(t, tab, 5*time.Second)
+
+	tab.saveMu.Lock()
+	failures := tab.saveFailures
+	tab.saveMu.Unlock()
+	if failures == 0 {
+		t.Fatal("autosave failure should be recorded and retried")
+	}
+	if info, err := os.Stat(path); err != nil || !info.IsDir() {
+		t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
+	}
+
+	if err := os.Remove(path); err != nil {
+		t.Fatalf("remove blocked dir: %v", err)
+	}
+	tab.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForFile(t, path, "remember this turn")
+	waitForAutosaveIdle(t, tab)
+
+	tab.saveMu.Lock()
+	failures = tab.saveFailures
+	tab.saveMu.Unlock()
+	if failures != 0 {
+		t.Fatalf("autosave failures after recovery = %d, want 0", failures)
+	}
+}
+
+func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	root := globalTabWorkspaceRoot()
+	dir := desktopSessionDir(root)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	originalPath := filepath.Join(dir, "session.jsonl")
+	originalTopic := "topic_original"
+	if err := setTopicTitle("", originalTopic, "Original"); err != nil {
+		t.Fatalf("set original topic title: %v", err)
+	}
+	current := agent.NewSession("sys")
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := current.Save(originalPath); err != nil {
+		t.Fatalf("Save current: %v", err)
+	}
+	if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
+		Scope:         "global",
+		TopicID:       originalTopic,
+		TopicTitle:    "Original",
+		Preview:       "first",
+		Turns:         2,
+		SchemaVersion: agent.BranchMetaCountsVersion,
+	}); err != nil {
+		t.Fatalf("SaveBranchMeta original: %v", err)
+	}
+
+	staleSess := agent.NewSession("sys")
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
+	app := &App{
+		tabs:             map[string]*WorkspaceTab{},
+		detachedSessions: map[string]*WorkspaceTab{},
+		activeTabID:      "recovery_tab",
+	}
+	tab := &WorkspaceTab{
+		ID:            "recovery_tab",
+		Scope:         "global",
+		WorkspaceRoot: root,
+		TopicID:       originalTopic,
+		TopicTitle:    "Original",
+		SessionPath:   originalPath,
+		Ready:         true,
+		model:         "test-model",
+		disabledMCP:   map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	tab.Ctrl = control.New(control.Options{
+		Executor:            staleExec,
+		SessionDir:          dir,
+		SessionPath:         originalPath,
+		Label:               "test",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	app.tabs[tab.ID] = tab
+
+	if err := tab.Ctrl.Snapshot(); err != nil {
+		t.Fatalf("Snapshot: %v", err)
+	}
+	recoveryPath := tab.Ctrl.SessionPath()
+	if recoveryPath == "" || recoveryPath == originalPath {
+		t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
+	}
+	if tab.SessionPath != recoveryPath {
+		t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
+	}
+	if tab.TopicID != originalTopic {
+		t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
+	}
+	meta, ok, err := agent.LoadBranchMeta(recoveryPath)
+	if err != nil || !ok {
+		t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
+	}
+	if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
+		t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
+	}
+	tabMeta := app.tabMeta(tab, true)
+	if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
+		t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
+	}
+	nodes := app.ListProjectTree()
+	foundOriginal := false
+	var walk func([]ProjectNode)
+	walk = func(list []ProjectNode) {
+		for _, node := range list {
+			if node.Recovered {
+				t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
+			}
+			if node.TopicID == originalTopic {
+				foundOriginal = true
+			}
+			walk(node.Children)
+		}
+	}
+	walk(nodes)
+	if !foundOriginal {
+		t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
+	}
+}
+
+func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	root := globalTabWorkspaceRoot()
+	dir := desktopSessionDir(root)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	originalPath := filepath.Join(dir, "session.jsonl")
+	current := agent.NewSession("sys")
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := current.Save(originalPath); err != nil {
+		t.Fatalf("Save current: %v", err)
+	}
+
+	staleSess := agent.NewSession("sys")
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
+		OriginalPath: originalPath,
+		BranchMeta: agent.BranchMeta{
+			Name:       agent.RecoveryBranchDefaultName,
+			Scope:      "global",
+			TopicID:    "topic_recovery",
+			TopicTitle: "Recovery",
+		},
+	})
+	if err != nil {
+		t.Fatalf("SaveRecoveryBranch: %v", err)
+	}
+	lease, err := agent.TryAcquireSessionLease(recovery.Path)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease recovery: %v", err)
+	}
+	defer lease.Release()
+
+	staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
+	runtimeEvents := make(chan runtimeEventEnvelope, 4)
+	app := &App{
+		ctx:              context.Background(),
+		tabs:             map[string]*WorkspaceTab{},
+		detachedSessions: map[string]*WorkspaceTab{},
+		activeTabID:      "recovery_tab",
+	}
+	app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
+		runtimeEvents <- runtimeEventEnvelope{
+			ctx:     ctx,
+			name:    name,
+			payload: append([]interface{}(nil), payload...),
+		}
+	}
+	tab := &WorkspaceTab{
+		ID:            "recovery_tab",
+		Scope:         "global",
+		WorkspaceRoot: root,
+		TopicID:       "topic_original",
+		TopicTitle:    "Original",
+		SessionPath:   originalPath,
+		Ready:         true,
+		model:         "test-model",
+		disabledMCP:   map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	tab.Ctrl = control.New(control.Options{
+		Executor:            staleExec,
+		SessionDir:          dir,
+		SessionPath:         originalPath,
+		Label:               "test",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	app.tabs[tab.ID] = tab
+
+	err = tab.Ctrl.Snapshot()
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if got := tab.Ctrl.SessionPath(); got != originalPath {
+		t.Fatalf("controller session path = %q, want original %q", got, originalPath)
+	}
+	if tab.SessionPath != originalPath {
+		t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
+	}
+	if tab.TopicID != "topic_original" {
+		t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
+	}
+
+	deadline := time.After(time.Second)
+	for {
+		select {
+		case emitted := <-runtimeEvents:
+			if emitted.name != "session:recovery-failed" {
+				continue
+			}
+			if len(emitted.payload) != 1 {
+				t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
+			}
+			failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
+			if !ok {
+				t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
+			}
+			if failed.Reason != "lease_held" {
+				t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
+			}
+			return
+		case <-deadline:
+			t.Fatal("session:recovery-failed event was not emitted")
+		}
+	}
+}
+
+func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, _ := appWithTab(t, path)
+	a.tabs["target_tab"] = &WorkspaceTab{
+		ID:          "target_tab",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	a.tabOrder = []string{"test_tab", "target_tab"}
+
+	err := a.SetActiveTab("target_tab")
+	if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
+		t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
+	}
+	if a.activeTabID != "test_tab" {
+		t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
+	}
+}
+
+func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, tab := appWithTab(t, path)
+	target := filepath.Join(t.TempDir(), "target.jsonl")
+	sess := agent.NewSession("system")
+	sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
+	if err := sess.Save(target); err != nil {
+		t.Fatalf("save target: %v", err)
+	}
+	loaded, err := agent.LoadSession(target)
+	if err != nil {
+		t.Fatalf("load target: %v", err)
+	}
+
+	err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
+	if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
+		t.Fatalf("rebind error = %v, want persistence failure", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
+		t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
+	}
+}
+
+// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
+// It proves that after CloseTab returns, the per-turn autosave goroutine can no
+// longer write the session file — even when it is in flight at the moment the
+// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
+// captured session path, so its Snapshot() call landed after DeleteSession
+// trashed the file, "resurrecting" it.
+func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+
+	doomed, doomedTab := appWithTab(t, path)
+	// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
+	survivor := &WorkspaceTab{
+		ID:          "survivor_tab",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
+	doomed.tabs["survivor_tab"] = survivor
+	doomed.activeTabID = "test_tab"
+
+	// Write the session file once via the autosave loop, then wait for idle so
+	// the next TurnDone reliably kicks off a fresh loop.
+	doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForFile(t, path, "acknowledged")
+	waitForAutosaveIdle(t, doomedTab)
+
+	// Kick the autosave loop and close the tab in close succession. The loop
+	// will be in flight when CloseTab runs — exactly the #4384 window.
+	doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
+	if err := doomed.CloseTab("test_tab"); err != nil {
+		t.Fatalf("CloseTab: %v", err)
+	}
+
+	// CloseTab must have returned only after the autosave loop finished. Remove
+	// the file the way DeleteSession would (move to trash is just a remove here
+	// since we only care that nothing rewrites the original path).
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		t.Fatalf("remove session file: %v", err)
+	}
+
+	// Give any would-be resurrection a chance to strike. If the autosave loop
+	// were still alive (the bug), the file reappears here.
+	time.Sleep(100 * time.Millisecond)
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
+	}
+
+	// And the controller's session path must be cleared so no future Snapshot
+	// can write either.
+	if got := doomedTab.Ctrl.SessionPath(); got != "" {
+		t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
+	}
+}
+
+func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, tab := appWithTab(t, path)
+	survivor := &WorkspaceTab{
+		ID:          "survivor_tab",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
+	a.tabs[survivor.ID] = survivor
+	a.tabOrder = []string{tab.ID, survivor.ID}
+
+	err := a.CloseTab(tab.ID)
+	if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
+		t.Fatalf("CloseTab error = %v, want persistence failure", err)
+	}
+	if _, ok := a.tabs[tab.ID]; !ok {
+		t.Fatal("tab was removed even though its session could not be saved")
+	}
+	if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
+		t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
+	}
+}
+
+// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
+// closing/drain logic is per-tab and must not leak to other tabs.
+func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
+	doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
+	survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
+
+	a, _ := appWithTab(t, doomedPath)
+	survivorCtrl := controllerWithContent(t, survivorPath)
+	survivor := &WorkspaceTab{
+		ID:          "survivor_tab",
+		Ctrl:        survivorCtrl,
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
+	a.tabs["survivor_tab"] = survivor
+	a.activeTabID = "test_tab"
+
+	survivor.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForFile(t, survivorPath, "acknowledged")
+	waitForAutosaveIdle(t, survivor)
+
+	if err := a.CloseTab("test_tab"); err != nil {
+		t.Fatalf("CloseTab: %v", err)
+	}
+
+	if got := survivor.Ctrl.SessionPath(); got != survivorPath {
+		t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
+	}
+	if survivor.closing {
+		t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
+	}
+}
+
+func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := t.TempDir()
+	path := filepath.Join(dir, "delete-open.jsonl")
+	ctrl := controllerWithContent(t, path)
+	tab := &WorkspaceTab{
+		ID:          "delete_open",
+		Scope:       "global",
+		Ready:       true,
+		Ctrl:        ctrl,
+		disabledMCP: map[string]ServerView{},
+	}
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"delete_open": tab},
+		activeTabID: "delete_open",
+	}
+	if err := ctrl.Snapshot(); err != nil {
+		t.Fatalf("snapshot: %v", err)
+	}
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession: %v", err)
+	}
+
+	if got := ctrl.SessionPath(); got != "" {
+		t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("session should be in trash: %v", err)
+	}
+}
+
+func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_clear_removed_runtime"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := t.TempDir()
+	path := filepath.Join(dir, "trash-open-topic.jsonl")
+	ctrl := controllerWithContent(t, path)
+	if err := ctrl.Snapshot(); err != nil {
+		t.Fatalf("snapshot: %v", err)
+	}
+	if err := agent.SaveBranchMeta(path, agent.BranchMeta{
+		CreatedAt:     time.Now().Add(-time.Minute),
+		UpdatedAt:     time.Now(),
+		Scope:         "project",
+		WorkspaceRoot: projectRoot,
+		TopicID:       topicID,
+		TopicTitle:    "Clear removed runtime",
+	}); err != nil {
+		t.Fatalf("save branch meta: %v", err)
+	}
+	tab := &WorkspaceTab{
+		ID:            "trash_open",
+		Scope:         "project",
+		WorkspaceRoot: projectRoot,
+		TopicID:       topicID,
+		TopicTitle:    "Clear removed runtime",
+		Ready:         true,
+		Ctrl:          ctrl,
+		disabledMCP:   map[string]ServerView{},
+	}
+	survivor := &WorkspaceTab{
+		ID:          "survivor",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
+		tabOrder:    []string{"trash_open", "survivor"},
+		activeTabID: "trash_open",
+	}
+
+	if err := app.TrashTopic(topicID); err != nil {
+		t.Fatalf("TrashTopic: %v", err)
+	}
+
+	if got := ctrl.SessionPath(); got != "" {
+		t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("topic session should be in trash: %v", err)
+	}
+}
diff --git a/desktop/app_platform_test.go b/desktop/app_platform_test.go
new file mode 100644
index 0000000..af6eea1
--- /dev/null
+++ b/desktop/app_platform_test.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+	"runtime"
+	"testing"
+)
+
+func TestAppPlatformReturnsRuntimeGOOS(t *testing.T) {
+	app := NewApp()
+
+	if got := app.Platform(); got != runtime.GOOS {
+		t.Fatalf("Platform() = %q, want %q", got, runtime.GOOS)
+	}
+}
diff --git a/desktop/app_session_dedup_test.go b/desktop/app_session_dedup_test.go
new file mode 100644
index 0000000..f3e8ca9
--- /dev/null
+++ b/desktop/app_session_dedup_test.go
@@ -0,0 +1,799 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/provider"
+	"reasonix/internal/tool"
+)
+
+func carryingController(carried []provider.Message, path string) *control.Controller {
+	sess := &agent.Session{}
+	sess.Replace(carried)
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
+}
+
+// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
+// config change rebuilds the controller and carries the conversation forward. Each
+// rebuild must keep writing to the same file, so a run of them leaves exactly one
+// history entry — not a new identical duplicate per rebuild.
+func TestCarriedRebuildsKeepOneSession(t *testing.T) {
+	dir := t.TempDir()
+	path := agent.NewSessionPath(dir, "model-a")
+	ctrl := controllerWithContent(t, path)
+	if err := ctrl.Snapshot(); err != nil {
+		t.Fatal(err)
+	}
+
+	for i := 0; i < 5; i++ {
+		prevPath := ctrl.SessionPath()
+		carried := ctrl.History()
+		ctrl.Close()
+
+		newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
+		ctrl = carryingController(carried, newPath)
+		if err := ctrl.Snapshot(); err != nil {
+			t.Fatal(err)
+		}
+	}
+	ctrl.Close()
+
+	infos, err := agent.ListSessions(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(infos) != 1 {
+		paths := make([]string, len(infos))
+		for i, s := range infos {
+			paths[i] = filepath.Base(s.Path)
+		}
+		t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
+	}
+}
+
+// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
+
+func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if first.SessionPath == "" {
+		t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
+	}
+	if _, err := os.Stat(first.SessionPath); err != nil {
+		t.Fatalf("pre-created blank session should exist: %v", err)
+	}
+	second, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.ID != first.ID {
+		t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
+	}
+	if tabs := app.ListTabs(); len(tabs) != 1 {
+		t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
+	}
+}
+
+func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	globalRoot := globalWorkspaceRoot()
+	if err := os.MkdirAll(globalRoot, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
+	if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	topic, err := app.CreateTopic("global", "", "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	app.tabs["blank"] = &WorkspaceTab{
+		ID:            "blank",
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		TopicID:       topic.ID,
+		TopicTitle:    defaultTopicTitle,
+		SessionPath:   sessionPath,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabOrder = []string{"blank"}
+	app.activeTabID = "blank"
+
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.ID != "blank" {
+		t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
+	}
+}
+
+func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	topic, err := app.CreateTopic("global", "", "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	globalRoot := globalWorkspaceRoot()
+	dir := desktopSessionDir(globalRoot)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	stubPath := filepath.Join(dir, "empty-stub.jsonl")
+	if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
+		t.Fatalf("write empty stub: %v", err)
+	}
+	now := time.Now()
+	if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
+		CreatedAt:     now.Add(-time.Minute),
+		UpdatedAt:     now,
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		TopicID:       topic.ID,
+		TopicTitle:    defaultTopicTitle,
+	}); err != nil {
+		t.Fatalf("save branch meta: %v", err)
+	}
+
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID != topic.ID {
+		t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
+	}
+}
+
+func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	before := time.Now().UnixMilli()
+	meta, err := app.EnsureBlankTab("global", "")
+	after := time.Now().UnixMilli()
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+
+	createdAt := loadTopicCreatedAt("", meta.TopicID)
+	if createdAt < before || createdAt > after {
+		t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
+	}
+
+	nodes := app.ListProjectTree()
+	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
+		t.Fatalf("project tree = %#v, want Global with one topic", nodes)
+	}
+	if got := nodes[0].Children[0].CreatedAt; got != createdAt {
+		t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
+	}
+}
+
+func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	const topicID = "topic_20260704-104018_deadbeef"
+	if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	if err := prependTopicInProjectsFile("", topicID, false); err != nil {
+		t.Fatalf("prepend topic: %v", err)
+	}
+	if got := loadTopicCreatedAt("", topicID); got != 0 {
+		t.Fatalf("createdAt before reuse = %d, want 0", got)
+	}
+
+	app := NewApp()
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID != topicID {
+		t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
+	}
+
+	expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
+	if got := loadTopicCreatedAt("", topicID); got != expected {
+		t.Fatalf("repaired createdAt = %d, want %d", got, expected)
+	}
+}
+
+// EnsureBlankTab reuses an already-open project-scoped blank tab.
+
+func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatal(err)
+	}
+	second, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.ID != first.ID {
+		t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
+	}
+	if tabs := app.ListTabs(); len(tabs) != 1 {
+		t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
+	}
+}
+
+func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectA)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project A): %v", err)
+	}
+	tabA := waitForTabReady(t, app, first.ID)
+	if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
+	}
+
+	second, err := app.EnsureBlankTab("project", projectB)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project B): %v", err)
+	}
+	if second.ID == first.ID {
+		t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
+	}
+	tabB := waitForTabReady(t, app, second.ID)
+
+	if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
+		t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
+	}
+	if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
+		t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
+	}
+	sys := systemPromptFrom(tabB.Ctrl.History())
+	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
+		t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
+	}
+	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
+		t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
+	}
+}
+
+func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
+	if err != nil {
+		t.Fatalf("create project A empty session: %v", err)
+	}
+	tab := &WorkspaceTab{
+		ID:            "blank-project-b",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		SessionPath:   pathA,
+	}
+
+	if blankTabSessionPathHasNoContent(tab) {
+		t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
+	}
+}
+
+func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectA)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project A): %v", err)
+	}
+	waitForTabReady(t, app, first.ID)
+
+	second, err := app.EnsureBlankTab("project", projectB)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project B): %v", err)
+	}
+	tabB := waitForTabReady(t, app, second.ID)
+	ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
+	turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
+
+	forked, err := app.Fork(turn)
+	if err != nil {
+		t.Fatalf("Fork: %v", err)
+	}
+	if forked.ID == "" || forked.ID == second.ID {
+		t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
+	}
+	forkTab := waitForTabReady(t, app, forked.ID)
+	if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	sys := systemPromptFrom(forkTab.Ctrl.History())
+	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
+		t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
+	}
+	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
+		t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
+	}
+}
+
+func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectA)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project A): %v", err)
+	}
+	waitForTabReady(t, app, first.ID)
+
+	second, err := app.EnsureBlankTab("project", projectB)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project B): %v", err)
+	}
+	tabB := waitForTabReady(t, app, second.ID)
+	ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
+	turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
+
+	if err := app.Rewind(turn, "conversation"); err != nil {
+		t.Fatalf("Rewind: %v", err)
+	}
+	if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	sys := systemPromptFrom(tabB.Ctrl.History())
+	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
+		t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
+	}
+	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
+		t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
+	}
+}
+
+func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
+	t.Helper()
+	if tab == nil || tab.Ctrl == nil {
+		t.Fatal("tab controller is required")
+	}
+	sys := systemPromptFrom(tab.Ctrl.History())
+	if strings.TrimSpace(sys) == "" {
+		t.Fatal("tab controller did not expose a system prompt")
+	}
+	sessionDir := tab.Ctrl.SessionDir()
+	sessionPath := tab.Ctrl.SessionPath()
+	workspaceRoot := tab.Ctrl.WorkspaceRoot()
+	label := tab.Ctrl.Label()
+	tab.Ctrl.Close()
+
+	sess := agent.NewSession(sys)
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	ctrl := control.New(control.Options{
+		Runner:        ag,
+		Executor:      ag,
+		SessionDir:    sessionDir,
+		SessionPath:   sessionPath,
+		WorkspaceRoot: workspaceRoot,
+		Label:         label,
+		SystemPrompt:  sys,
+		Sink:          event.Discard,
+	})
+	tab.Ctrl = ctrl
+	app.bindControllerDisplayRecorder(ctrl)
+	return ctrl
+}
+
+func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
+	t.Helper()
+	ctrl.SubmitUserTurn(input, input)
+	waitNotRunning(t, ctrl)
+
+	deadline := time.Now().Add(time.Second)
+	for {
+		checkpoints := ctrl.Checkpoints()
+		if len(checkpoints) > 0 {
+			return checkpoints[len(checkpoints)-1].Turn
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("controller did not record a checkpoint")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
+		t.Fatalf("set stale auto title: %v", err)
+	}
+	tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
+	app.tabs[tab.ID] = tab
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if got := meta.TopicTitle; got != defaultTopicTitle {
+		t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
+	}
+	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
+		t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
+	}
+	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
+		t.Fatalf("title source = %q, want auto", got)
+	}
+}
+
+func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "Manual title")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
+	app.tabs[tab.ID] = tab
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if got := meta.TopicTitle; got != "Manual title" {
+		t.Fatalf("reused manual topic title = %q, want Manual title", got)
+	}
+	if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
+		t.Fatalf("stored title = %q, want Manual title", got)
+	}
+	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
+		t.Fatalf("title source = %q, want manual", got)
+	}
+}
+
+func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
+		t.Fatalf("set stale auto title: %v", err)
+	}
+	activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
+	reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
+	app.tabs[activeTab.ID] = activeTab
+	app.tabs[reusableTab.ID] = reusableTab
+	app.tabOrder = []string{activeTab.ID, reusableTab.ID}
+	app.activeTabID = activeTab.ID
+
+	titlePath := topicTitlesPath(projectRoot)
+	if err := os.Remove(titlePath); err != nil {
+		t.Fatalf("remove title file: %v", err)
+	}
+	if err := os.Mkdir(titlePath, 0o755); err != nil {
+		t.Fatalf("replace title file with directory: %v", err)
+	}
+
+	if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
+		t.Fatal("EnsureBlankTab succeeded, want title reset error")
+	}
+	if got := app.activeTabID; got != activeTab.ID {
+		t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
+	}
+}
+
+// EnsureBlankTab picks up an existing blank topic created in the sidebar
+// instead of creating a fresh topic, for global scope.
+
+func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	topic, err := app.CreateTopic("global", "", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if meta.TopicID != topic.ID {
+		t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
+	}
+	if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
+		t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
+	}
+}
+
+// EnsureBlankTab picks up an existing blank topic created in the sidebar
+// instead of creating a fresh topic, for project scope.
+
+func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if meta.TopicID != topic.ID {
+		t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
+	}
+	var topics []string
+	for _, project := range loadProjectsFile().Projects {
+		if project.Root == projectRoot {
+			topics = project.Topics
+			break
+		}
+	}
+	if len(topics) != 1 {
+		t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
+	}
+}
+
+func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := robustTempDir(t)
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatalf("CreateTopic: %v", err)
+	}
+	dir := desktopSessionDir(projectRoot)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
+	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
+		t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
+	}
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID == topic.ID {
+		t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
+	}
+	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
+		t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
+	}
+}
+
+// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
+// into ensureTopicIndexed, whose intentional prepend clears the delete
+// tombstone and resurrects the topic the user removed.
+func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	// Race product on disk: deleted topic whose default title lingered in the
+	// global title map (title-only, absent from GlobalTopics, no sessions).
+	tombstonedID := "topic_tombstone_blank"
+	if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
+		t.Fatalf("set lingering title: %v", err)
+	}
+	if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
+		f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
+		return true, nil
+	}); err != nil {
+		t.Fatalf("seed tombstone: %v", err)
+	}
+
+	meta, err := NewApp().EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID == tombstonedID {
+		t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
+	}
+	f := loadProjectsFile()
+	if !containsDesktopString(f.DeletedTopics, tombstonedID) {
+		t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
+	}
+	if containsDesktopString(f.GlobalTopics, tombstonedID) {
+		t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
+	}
+}
+
+// NewSession skips the snapshot when the current tab has no real conversation content.
+
+func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := t.TempDir()
+	path := agent.NewSessionPath(dir, "model-a")
+	ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
+	app := NewApp()
+	app.setTestCtrl(ctrl, "model-a")
+
+	if err := app.NewSession(); err != nil {
+		t.Fatal(err)
+	}
+	if got := ctrl.SessionPath(); got != path {
+		t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
+	}
+}
+
+func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	oldTopicID := "topic_old"
+	oldTopicTitle := "Old topic"
+	oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
+	sess := &agent.Session{}
+	sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "model-a")
+	tab := app.tabs["test"]
+	tab.Scope = "project"
+	tab.WorkspaceRoot = projectRoot
+	tab.TopicID = oldTopicID
+	tab.TopicTitle = oldTopicTitle
+	tab.SessionPath = oldPath
+	app.projectTreeChangedHook = func() {}
+
+	if err := app.NewSession(); err != nil {
+		t.Fatalf("NewSession: %v", err)
+	}
+	if got := tab.TopicID; got == "" || got == oldTopicID {
+		t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
+	}
+	if got := tab.TopicTitle; got != defaultTopicTitle {
+		t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
+	}
+	newPath := ctrl.SessionPath()
+	if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
+		t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
+	}
+
+	if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write new session: %v", err)
+	}
+	if !app.maybeAutoTitleTopic(tab) {
+		t.Fatalf("new session should auto-title its fresh topic")
+	}
+
+	oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
+	if err != nil || !ok {
+		t.Fatalf("load old meta: ok=%v err=%v", ok, err)
+	}
+	if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
+		t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
+	}
+	newMeta, ok, err := agent.LoadBranchMeta(newPath)
+	if err != nil || !ok {
+		t.Fatalf("load new meta: ok=%v err=%v", ok, err)
+	}
+	if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
+		t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
+	}
+}
+
+func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	path := agent.NewSessionPath(dir, "model-a")
+	ctrl := controllerWithContent(t, path)
+	app := NewApp()
+	app.projectTreeChangedHook = func() {}
+	app.setTestCtrl(ctrl, "model-a")
+	tab := app.tabs["test"]
+	tab.TopicID = "topic_old"
+	tab.TopicTitle = "Old topic"
+
+	// Block desktopConfigDir-backed topic-index writes without affecting the
+	// session directory, which exercises the post-NewSession repair failure path.
+	if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
+		t.Fatalf("mkdir desktop config parent: %v", err)
+	}
+	if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
+		t.Fatalf("block desktop config dir: %v", err)
+	}
+
+	if err := app.NewSession(); err != nil {
+		t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
+	}
+	if got := tab.TopicID; got == "" || got == "topic_old" {
+		t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
+	}
+	if got := tab.TopicTitle; got != defaultTopicTitle {
+		t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
+	}
+	if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
+		t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
+	}
+}
diff --git a/desktop/app_test.go b/desktop/app_test.go
new file mode 100644
index 0000000..74b0ccd
--- /dev/null
+++ b/desktop/app_test.go
@@ -0,0 +1,8718 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"reflect"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/billing"
+	"reasonix/internal/boot"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/jobs"
+	"reasonix/internal/memory"
+	"reasonix/internal/plugin"
+	"reasonix/internal/pluginpkg"
+	"reasonix/internal/provider"
+	"reasonix/internal/sandbox"
+	"reasonix/internal/skill"
+	"reasonix/internal/store"
+	"reasonix/internal/tool"
+)
+
+func desktopMCPHTTPServer(t *testing.T) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var req struct {
+			ID     *int            `json:"id"`
+			Method string          `json:"method"`
+			Params json.RawMessage `json:"params"`
+		}
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			http.Error(w, "bad body", http.StatusBadRequest)
+			return
+		}
+		if req.ID == nil {
+			w.WriteHeader(http.StatusAccepted)
+			return
+		}
+		var result any
+		switch req.Method {
+		case "initialize":
+			result = map[string]any{
+				"protocolVersion": "2024-11-05",
+				"serverInfo":      map[string]any{"name": "h", "version": "0"},
+			}
+		case "tools/list":
+			result = map[string]any{"tools": []map[string]any{{
+				"name":        "greet",
+				"description": "Greet someone.",
+				"inputSchema": map[string]any{"type": "object"},
+			}}}
+		default:
+			result = map[string]any{}
+		}
+		resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(resp)
+	}))
+}
+
+// setTestCtrl creates a minimal workspace tab (if needed) and sets its
+// controller, so tests don't depend on the old App.ctrl field.
+func (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {
+	if len(a.tabs) == 0 {
+		tab := &WorkspaceTab{
+			ID:          "test",
+			Scope:       "global",
+			Ready:       true,
+			disabledMCP: map[string]ServerView{},
+		}
+		a.tabs = map[string]*WorkspaceTab{"test": tab}
+		a.activeTabID = "test"
+	}
+	tab := a.tabs["test"]
+	tab.Ctrl = ctrl
+	a.bindControllerDisplayRecorder(ctrl)
+	tab.model = model
+}
+
+func isolateDesktopUserDirs(t *testing.T) string {
+	t.Helper()
+	home := robustTempDir(t)
+	xdg := filepath.Join(home, ".config")
+	appData := filepath.Join(home, "AppData")
+	for _, dir := range []string{xdg, appData} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			t.Fatal(err)
+		}
+	}
+	t.Setenv("HOME", home)
+	t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
+	t.Setenv("USERPROFILE", home)
+	t.Setenv("XDG_CONFIG_HOME", xdg)
+	t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
+	t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
+	t.Setenv("AppData", appData)
+	return home
+}
+
+func primarySessionFiles(paths []string) []string {
+	out := make([]string, 0, len(paths))
+	for _, path := range paths {
+		if store.IsSessionTranscriptName(filepath.Base(path)) {
+			out = append(out, path)
+		}
+	}
+	return out
+}
+
+func readConflictLogLines(t *testing.T, path string) []string {
+	t.Helper()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read conflict log: %v", err)
+	}
+	text := strings.TrimSpace(string(data))
+	if text == "" {
+		return nil
+	}
+	return strings.Split(text, "\n")
+}
+
+func setDesktopTestCredential(t *testing.T, key, value string) {
+	t.Helper()
+	if _, err := config.SetCredential(key, value); err != nil {
+		t.Fatalf("SetCredential(%s): %v", key, err)
+	}
+}
+
+func TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv(onboardingKeyEnv, "inherited-key")
+
+	app := NewApp()
+	if !app.NeedsOnboarding() {
+		t.Fatal("NeedsOnboarding should require a key saved in Reasonix global .env")
+	}
+	setDesktopTestCredential(t, onboardingKeyEnv, "saved-key")
+	if app.NeedsOnboarding() {
+		t.Fatal("NeedsOnboarding should be false after saving the global credential")
+	}
+}
+
+func TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+"=\n"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	if !app.NeedsOnboarding() {
+		t.Fatal("NeedsOnboarding should require a non-empty saved credential")
+	}
+}
+
+func providerNamesFromView(providers []ProviderView) []string {
+	out := make([]string, 0, len(providers))
+	for _, p := range providers {
+		out = append(out, p.Name)
+	}
+	return out
+}
+
+func modelRefsFromView(models []ModelInfo) map[string]bool {
+	out := map[string]bool{}
+	for _, m := range models {
+		out[m.Ref] = true
+	}
+	return out
+}
+
+type desktopFakeTool struct {
+	name string
+}
+
+func (t desktopFakeTool) Name() string { return t.name }
+
+func (desktopFakeTool) Description() string { return "fake desktop tool" }
+
+func (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
+
+func (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
+
+func (desktopFakeTool) ReadOnly() bool { return true }
+
+type desktopAskRuntimeRunner struct {
+	ask func(context.Context) error
+}
+
+func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
+	if r.ask == nil {
+		return nil
+	}
+	return r.ask(ctx)
+}
+
+func TestCommandsIncludesEffortNotThinking(t *testing.T) {
+	app := NewApp()
+	cmds := app.Commands()
+	if !hasCommand(cmds, "effort") {
+		t.Fatalf("Commands() should include effort: %+v", cmds)
+	}
+	if hasCommand(cmds, "thinking") {
+		t.Fatalf("Commands() should not include thinking: %+v", cmds)
+	}
+}
+
+func TestCommandsClassifiesSubagentSkills(t *testing.T) {
+	ctrl := control.New(control.Options{Skills: []skill.Skill{
+		{Name: "init", Description: "inline skill", RunAs: skill.RunInline},
+		{Name: "explore", Description: "isolated skill", RunAs: skill.RunSubagent, Color: "amber"},
+	}})
+	defer ctrl.Close()
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+
+	kinds := map[string]string{}
+	groups := map[string]string{}
+	colors := map[string]string{}
+	for _, cmd := range app.Commands() {
+		kinds[cmd.Name] = cmd.Kind
+		groups[cmd.Name] = cmd.Group
+		colors[cmd.Name] = cmd.Color
+	}
+	if kinds["init"] != "skill" {
+		t.Fatalf("inline skill kind = %q, want skill", kinds["init"])
+	}
+	if kinds["explore"] != "subagent" {
+		t.Fatalf("subagent skill kind = %q, want subagent", kinds["explore"])
+	}
+	if colors["explore"] != "amber" {
+		t.Fatalf("subagent skill color = %q, want amber", colors["explore"])
+	}
+	if groups["new"] != "actions" {
+		t.Fatalf("new command group = %q, want actions", groups["new"])
+	}
+	if groups["mcp"] != "integrations" || groups["plugins"] != "integrations" {
+		t.Fatalf("integration command groups = mcp:%q plugins:%q", groups["mcp"], groups["plugins"])
+	}
+	if groups["skill"] != "skills" {
+		t.Fatalf("skill command group = %q, want skills", groups["skill"])
+	}
+}
+
+func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
+	if _, err := exec.LookPath("git"); err != nil {
+		t.Skip("git not installed")
+	}
+	isolateDesktopUserDirs(t)
+	resetWorkspaceGitBranchMetaCacheForTest(t)
+
+	repo := t.TempDir()
+	configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatal(err)
+	}
+
+	orig, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := os.Chdir(orig); err != nil {
+			t.Fatal(err)
+		}
+	}()
+	if err := os.Chdir(repo); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, "init")
+	runGit(t, "checkout", "-b", "feature/meta")
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"tab-1": {
+		ID:            "tab-1",
+		Scope:         "project",
+		WorkspaceRoot: repo,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}}
+	app.activeTabID = "tab-1"
+
+	got := app.MetaForTab("tab-1")
+	if got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {
+		t.Fatalf("workspace fields = cwd:%q root:%q path:%q, want %q", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)
+	}
+	if got.WorkspaceName != filepath.Base(repo) {
+		t.Fatalf("workspaceName = %q, want %q", got.WorkspaceName, filepath.Base(repo))
+	}
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("marshal meta: %v", err)
+	}
+	if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
+		t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
+	}
+	deadline := time.Now().Add(time.Second)
+	for {
+		if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
+			break
+		}
+		if time.Now().After(deadline) {
+			t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch)
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	workspace := t.TempDir()
+	configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"tab-1": {
+		ID:            "tab-1",
+		Scope:         "project",
+		WorkspaceRoot: workspace,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}}
+	app.activeTabID = "tab-1"
+	app.tabOrder = []string{"tab-1"}
+
+	raw, err := json.Marshal(app.ListTabs())
+	if err != nil {
+		t.Fatalf("marshal tabs: %v", err)
+	}
+	if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
+		t.Fatalf("tab metadata should not expose configured sandbox root as sandboxPath: %s", raw)
+	}
+}
+
+func TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {
+	asks := make(chan event.Ask, 1)
+	done := make(chan event.Event, 1)
+	runner := &desktopAskRuntimeRunner{}
+	ctrl := control.New(control.Options{
+		Runner: runner,
+		Sink: event.FuncSink(func(e event.Event) {
+			switch e.Kind {
+			case event.AskRequest:
+				asks <- e.Ask
+			case event.TurnDone:
+				done <- e
+			}
+		}),
+	})
+	runner.ask = func(ctx context.Context) error {
+		_, err := ctrl.Ask(ctx, []event.AskQuestion{{
+			ID:      "choice",
+			Prompt:  "Pick one",
+			Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
+		}})
+		return err
+	}
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "prov/model")
+	app.tabOrder = []string{"test"}
+	ctrl.Send("ask user")
+	select {
+	case <-asks:
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for ask request")
+	}
+
+	tabs := app.ListTabs()
+	if len(tabs) != 1 {
+		t.Fatalf("tabs = %d, want 1", len(tabs))
+	}
+	if !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {
+		t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v cancel:%v", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)
+	}
+
+	app.CancelTab("test")
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for turn_done")
+	}
+}
+
+func TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	workspace := t.TempDir()
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"tab-1": {
+		ID:            "tab-1",
+		Scope:         "project",
+		WorkspaceRoot: workspace,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}}
+	app.activeTabID = "tab-1"
+
+	if got := app.MetaForTab("tab-1"); got.GitBranch != "" {
+		t.Fatalf("gitBranch = %q, want empty", got.GitBranch)
+	}
+}
+
+func TestEffortDefaultsBeforeStartup(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	got := NewApp().Effort()
+	if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
+		t.Fatalf("pre-startup Effort() = %+v, want auto with DeepSeek default high", got)
+	}
+}
+
+func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	view := NewApp().Memory()
+	if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {
+		t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
+	}
+	raw, err := json.Marshal(view)
+	if err != nil {
+		t.Fatalf("marshal Memory(): %v", err)
+	}
+	for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} {
+		if strings.Contains(string(raw), bad) {
+			t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
+		}
+	}
+}
+
+func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	userDir := t.TempDir()
+	cwd := t.TempDir()
+	store := memory.Store{Dir: filepath.Join(userDir, "projects", "test", "memory")}
+	if _, err := store.Save(memory.Memory{
+		Name:        "active-fact",
+		Title:       "Active fact",
+		Description: "Still applies",
+		Type:        memory.TypeProject,
+		Body:        "Active body",
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := store.Save(memory.Memory{
+		Name:        "archived-fact",
+		Description: "No longer applies",
+		Type:        memory.TypeFeedback,
+		Body:        "Archived body",
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := store.Archive("archived-fact"); err != nil {
+		t.Fatalf("Archive: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
+		Docs:    []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}},
+		Store:   store,
+		CWD:     cwd,
+		UserDir: userDir,
+	}}), "test-model")
+
+	view := app.Memory()
+	if !view.Available || view.StoreDir != store.Dir {
+		t.Fatalf("Memory() availability/store = %v/%q, want true/%q", view.Available, view.StoreDir, store.Dir)
+	}
+	if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
+		t.Fatalf("Memory() docs = %+v", view.Docs)
+	}
+	if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" {
+		t.Fatalf("Memory() active facts = %+v", view.Facts)
+	}
+	if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" ||
+		view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
+		t.Fatalf("Memory() archived facts = %+v", view.Archives)
+	}
+	if len(view.Scopes) != 3 {
+		t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
+	}
+}
+
+func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	consumeSystemQuitRequested()
+	t.Cleanup(func() { consumeSystemQuitRequested() })
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
+		t.Fatal(err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatal(err)
+	}
+
+	markSystemQuitRequested()
+	if prevent := NewApp().beforeClose(context.Background()); prevent {
+		t.Fatal("system quit should bypass background close-to-tray behavior")
+	}
+	if consumeSystemQuitRequested() {
+		t.Fatal("system quit marker should be consumed by beforeClose")
+	}
+}
+
+func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {
+	tests := []struct {
+		goos string
+		want bool
+	}{
+		{goos: "darwin", want: true},
+		{goos: "windows", want: false},
+		{goos: "linux", want: false},
+		{goos: "freebsd", want: false},
+	}
+	for _, tt := range tests {
+		if got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {
+			t.Fatalf("backgroundCloseUsesApplicationHide(%q) = %v, want %v", tt.goos, got, tt.want)
+		}
+	}
+}
+
+func TestBackgroundCloseRequiresRestorePath(t *testing.T) {
+	tests := []struct {
+		name        string
+		goos        string
+		trayStarted bool
+		trayReady   bool
+		want        bool
+	}{
+		{name: "macOS restores from Dock", goos: "darwin", trayStarted: false, trayReady: false, want: true},
+		{name: "Windows tray ready", goos: "windows", trayStarted: true, trayReady: true, want: true},
+		{name: "Windows tray started but not ready", goos: "windows", trayStarted: true, trayReady: false, want: false},
+		{name: "Linux tray ready", goos: "linux", trayStarted: true, trayReady: true, want: true},
+		{name: "Linux tray started but not ready", goos: "linux", trayStarted: true, trayReady: false, want: false},
+		{name: "Linux no tray", goos: "linux", trayStarted: false, trayReady: false, want: false},
+		{name: "other Unix no tray", goos: "freebsd", trayStarted: false, trayReady: false, want: false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := backgroundCloseHasRestorePathFor(tt.goos, tt.trayStarted, tt.trayReady); got != tt.want {
+				t.Fatalf("backgroundCloseHasRestorePathFor(%q, %v, %v) = %v, want %v", tt.goos, tt.trayStarted, tt.trayReady, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestBackgroundCloseReadySignalRequiresCurrentReadyState(t *testing.T) {
+	app := NewApp()
+	tray := newDesktopTray()
+	app.mu.Lock()
+	app.tray = tray
+	app.mu.Unlock()
+
+	if app.waitForTrayReady(0) {
+		t.Fatal("tray should not be ready before its ready signal")
+	}
+
+	tray.markReady()
+	if app.waitForTrayReady(0) {
+		t.Fatal("closed ready signal should not count without the current ready state")
+	}
+
+	app.mu.Lock()
+	app.trayReady = true
+	app.mu.Unlock()
+	if !app.waitForTrayReady(0) {
+		t.Fatal("ready state should be accepted after the tray is marked ready")
+	}
+
+	app.mu.Lock()
+	app.trayReady = false
+	app.mu.Unlock()
+	if app.waitForTrayReady(0) {
+		t.Fatal("stale ready signal should not count after the tray exits")
+	}
+}
+
+func TestBackgroundCloseWaitsForTrayReadySignal(t *testing.T) {
+	app := NewApp()
+	tray := newDesktopTray()
+	app.mu.Lock()
+	app.tray = tray
+	app.mu.Unlock()
+
+	go func() {
+		time.Sleep(10 * time.Millisecond)
+		app.mu.Lock()
+		app.trayReady = true
+		app.mu.Unlock()
+		tray.markReady()
+	}()
+
+	if !app.waitForTrayReady(200 * time.Millisecond) {
+		t.Fatal("waitForTrayReady should observe the tray becoming ready")
+	}
+}
+
+func TestBackgroundRestoreMaximiseStrategy(t *testing.T) {
+	tests := []struct {
+		goos      string
+		maximised bool
+		want      bool
+	}{
+		{goos: "windows", maximised: true, want: true},
+		{goos: "linux", maximised: true, want: true},
+		{goos: "darwin", maximised: true, want: false},
+		{goos: "windows", maximised: false, want: false},
+	}
+	for _, tt := range tests {
+		if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {
+			t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
+		}
+	}
+}
+
+func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
+	tests := []struct {
+		name      string
+		goos      string
+		maximised bool
+		want      backgroundRestorePlan
+	}{
+		{
+			name:      "maximised Windows window",
+			goos:      "windows",
+			maximised: true,
+			want:      backgroundRestorePlan{maximiseBeforeShow: true},
+		},
+		{
+			name:      "normal Windows window",
+			goos:      "windows",
+			maximised: false,
+			want:      backgroundRestorePlan{unminimiseAfterShow: true},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := backgroundRestorePlanFor(tt.goos, tt.maximised)
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestEmitReadyInvokesReadyHook(t *testing.T) {
+	app := NewApp()
+	var calls int32
+	app.readyHook = func() {
+		atomic.AddInt32(&calls, 1)
+	}
+
+	app.emitReady(context.TODO())
+
+	if got := atomic.LoadInt32(&calls); got != 1 {
+		t.Fatalf("ready hook calls = %d, want 1", got)
+	}
+}
+
+func TestSetEffortPersistsAndAutoClears(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	if err := app.SetEffort("max"); err != nil {
+		t.Fatalf("SetEffort(max): %v", err)
+	}
+	if got := app.Effort().Current; got != "max" {
+		t.Fatalf("Effort current = %q, want max", got)
+	}
+	if err := app.SetEffort("auto"); err != nil {
+		t.Fatalf("SetEffort(auto): %v", err)
+	}
+	if got := app.Effort().Current; got != "auto" {
+		t.Fatalf("Effort current = %q, want auto", got)
+	}
+	body, err := os.ReadFile(config.UserConfigPath())
+	if err != nil {
+		t.Fatalf("read saved config: %v", err)
+	}
+	if strings.Contains(string(body), `effort      = "max"`) {
+		t.Fatalf("auto should clear explicit max effort:\n%s", body)
+	}
+}
+
+func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[desktop]
+language = "zh"
+layout_style = "workbench"
+theme = "light"
+theme_style = "glacier"
+close_behavior = "quit"
+status_bar_style = "icon"
+status_bar_items = ["cost", "balance"]
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("set desktop language: %v", err)
+	}
+	if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
+		t.Fatalf("set desktop layout style: %v", err)
+	}
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
+		t.Fatalf("set desktop close behavior: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil {
+		t.Fatalf("set desktop status bar style: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil {
+		t.Fatalf("set desktop status bar items: %v", err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	got := NewApp().Settings()
+	if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
+		t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)
+	}
+	if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
+		t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want)
+	}
+}
+
+func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("set desktop language: %v", err)
+	}
+	if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
+		t.Fatalf("set desktop layout style: %v", err)
+	}
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
+		t.Fatalf("set desktop status bar style: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil {
+		t.Fatalf("set desktop status bar items: %v", err)
+	}
+	if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
+		t.Fatalf("set desktop check updates: %v", err)
+	}
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	got := NewApp().DesktopStartupSettings()
+	if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates {
+		t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
+	}
+	if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
+		t.Fatalf("DesktopStartupSettings status bar items = %v, want %v", got.StatusBarItems, want)
+	}
+	if !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{"alice"}) {
+		t.Fatalf("DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot", got.Bot)
+	}
+
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("marshal DesktopStartupSettings: %v", err)
+	}
+	if strings.Contains(string(raw), "providers") || strings.Contains(string(raw), "officialProviders") || strings.Contains(string(raw), "providerKinds") {
+		t.Fatalf("DesktopStartupSettings must not include full Settings provider payload: %s", raw)
+	}
+}
+
+func BenchmarkDesktopSettingsPayloads(b *testing.B) {
+	home := b.TempDir()
+	xdg := filepath.Join(home, ".config")
+	appData := filepath.Join(home, "AppData")
+	for _, dir := range []string{xdg, appData} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			b.Fatal(err)
+		}
+	}
+	b.Setenv("HOME", home)
+	b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
+	b.Setenv("USERPROFILE", home)
+	b.Setenv("XDG_CONFIG_HOME", xdg)
+	b.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
+	b.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
+	b.Setenv("AppData", appData)
+	b.Setenv("SHARED_PROVIDER_KEY", "sk-test")
+
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	for i := 0; i < 40; i++ {
+		cfg.Providers = append(cfg.Providers, config.ProviderEntry{
+			Name:      fmt.Sprintf("custom-%02d", i),
+			Kind:      "openai",
+			BaseURL:   "https://example.invalid/v1",
+			APIKeyEnv: "SHARED_PROVIDER_KEY",
+			Models:    []string{"model-a", "model-b"},
+			Default:   "model-a",
+		})
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		b.Fatalf("save config: %v", err)
+	}
+	app := NewApp()
+
+	b.Run("Settings", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			_ = app.Settings()
+		}
+	})
+	b.Run("DesktopStartupSettings", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			_ = app.DesktopStartupSettings()
+		}
+	})
+}
+
+func TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	launch := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil {
+		t.Fatalf("write project env: %v", err)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.UpsertProvider(config.ProviderEntry{
+		Name:      "workspace-provider",
+		Kind:      "openai",
+		BaseURL:   "https://workspace.example/v1",
+		Model:     "workspace-model",
+		APIKeyEnv: "WORKSPACE_ONLY_KEY",
+	}); err != nil {
+		t.Fatalf("upsert provider: %v", err)
+	}
+	userCfg.Desktop.ProviderAccess = []string{"workspace-provider"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+	t.Setenv("WORKSPACE_ONLY_KEY", "")
+	os.Unsetenv("WORKSPACE_ONLY_KEY")
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(launch); err != nil {
+		t.Fatalf("chdir launch: %v", err)
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
+	app.activeTabID = "project"
+	got := app.Settings()
+	for _, p := range got.Providers {
+		if p.Name == "workspace-provider" {
+			if p.KeySet {
+				t.Fatalf("workspace provider keySet = true, want false because workspace .env is ignored: %+v", p)
+			}
+			if p.Configured {
+				t.Fatalf("workspace provider configured = true, want false because workspace .env is ignored: %+v", p)
+			}
+			return
+		}
+	}
+	t.Fatalf("workspace provider missing from settings: %+v", got.Providers)
+}
+
+func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	launch := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil {
+		t.Fatalf("write project env: %v", err)
+	}
+	if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil {
+		t.Fatalf("SetCredential: %v", err)
+	}
+	userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	if err := userCfg.UpsertProvider(config.ProviderEntry{
+		Name:      "settings-provider",
+		Kind:      "openai",
+		BaseURL:   "https://settings.example/v1",
+		Model:     "settings-model",
+		APIKeyEnv: "SHARED_SETTINGS_KEY",
+	}); err != nil {
+		t.Fatalf("upsert provider: %v", err)
+	}
+	userCfg.Desktop.ProviderAccess = []string{"settings-provider"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+	t.Setenv("SHARED_SETTINGS_KEY", "from-project")
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(launch); err != nil {
+		t.Fatalf("chdir launch: %v", err)
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
+	app.activeTabID = "project"
+	got := app.Settings()
+	for _, p := range got.Providers {
+		if p.Name != "settings-provider" {
+			continue
+		}
+		if !p.KeySet || !strings.Contains(p.KeySource, "Reasonix credentials") {
+			t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p)
+		}
+		if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" {
+			t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env)
+		}
+		return
+	}
+	t.Fatalf("settings provider missing from settings: %+v", got.Providers)
+}
+
+func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+default_model = "legacy-provider/legacy-model"
+
+[desktop]
+language = "zh"
+layout_style = "workbench"
+theme = "light"
+theme_style = "glacier"
+close_behavior = "quit"
+status_bar_style = "text"
+status_bar_items = ["model", "cache", "balance"]
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	app := NewApp()
+	got := app.Settings()
+	if got.ConfigPath != config.UserConfigPath() {
+		t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
+	}
+	if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" {
+		t.Fatalf("Settings did not seed from legacy project config: %+v", got)
+	}
+	if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
+		t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
+	}
+	if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
+		t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
+	}
+	if err := app.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("SetDesktopLanguage: %v", err)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" {
+		t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
+	}
+	if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
+		t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
+	}
+}
+
+func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-flash"
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	app := NewApp()
+	if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
+		t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
+	}
+	if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
+		t.Fatalf("SetSubagentModel: %v", err)
+	}
+	if err := app.SetSubagentEffort("max"); err != nil {
+		t.Fatalf("SetSubagentEffort: %v", err)
+	}
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth(1): %v", err)
+	}
+	if err := app.SetMaxSubagentDepth(2); err != nil {
+		t.Fatalf("SetMaxSubagentDepth(2): %v", err)
+	}
+
+	got := app.Settings()
+	if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
+		t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
+	}
+	if got.Agent.MaxSubagentDepth != 2 {
+		t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
+		t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
+	}
+	if cfg.Agent.MaxSubagentDepth != 2 {
+		t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
+	}
+}
+
+func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	got := NewApp().Settings()
+	providers := providerAccessSet(providerNamesFromView(got.Providers))
+	official := providerAccessSet(providerNamesFromView(got.OfficialProviders))
+	if providers["mimo-api"] {
+		t.Fatalf("mimo-api should not be mixed into configured providers: %+v", got.Providers)
+	}
+	if !official["deepseek"] || official["mimo-api"] || official["mimo-token-plan"] {
+		t.Fatalf("official providers = %+v, want only deepseek", got.OfficialProviders)
+	}
+}
+
+func TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash"
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Name != "deepseek" {
+			continue
+		}
+		if !p.BuiltIn {
+			t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
+		}
+		if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
+			t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
+		}
+		if got.DefaultModel != "deepseek/deepseek-v4-flash" {
+			t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", got.DefaultModel)
+		}
+		return
+	}
+	t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
+}
+
+func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-Flash"
+
+[desktop]
+provider_access = ["deepseek"]
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://opencode.ai/zen/go/v1"
+models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
+default = "deepseek-v4-Flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	var custom *ProviderView
+	for i := range got.Providers {
+		if got.Providers[i].Name == "deepseek" {
+			custom = &got.Providers[i]
+			break
+		}
+	}
+	if custom == nil {
+		t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
+	}
+	if custom.BuiltIn {
+		t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
+	}
+	if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
+		t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
+	}
+	for _, p := range got.OfficialProviders {
+		if p.Name == "deepseek" && p.Added {
+			t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
+		}
+	}
+}
+
+func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash/deepseek-v4-pro"
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+
+[[providers]]
+name = "mimo-pro"
+kind = "openai"
+base_url = "https://token-plan-cn.xiaomimimo.com/v1"
+model = "mimo-v2.5-pro"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	providers := map[string]ProviderView{}
+	for _, p := range got.Providers {
+		providers[p.Name] = p
+	}
+	if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
+		t.Fatalf("deepseek provider = %+v, want inferred added key-set provider", providers["deepseek"])
+	}
+	if !providers["mimo-pro"].Added || !providers["mimo-pro"].KeySet || providers["mimo-pro"].BuiltIn {
+		t.Fatalf("mimo-pro provider = %+v, want inferred custom key-set provider", providers["mimo-pro"])
+	}
+	if got.DefaultModel != "deepseek/deepseek-v4-pro" {
+		t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", got.DefaultModel)
+	}
+}
+
+func TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash/deepseek-v4-flash"
+
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Added {
+			t.Fatalf("provider %+v should not be inferred as added when provider_access is explicit empty", p)
+		}
+	}
+}
+
+func TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	got := NewApp().Settings()
+	providers := map[string]ProviderView{}
+	for _, p := range got.Providers {
+		providers[p.Name] = p
+	}
+	if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
+		t.Fatalf("deepseek provider = %+v, want inferred added provider from configured key", providers["deepseek"])
+	}
+	if _, ok := providers["mimo-token-plan"]; ok {
+		t.Fatalf("mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v", providers["mimo-token-plan"])
+	}
+}
+
+func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	t.Setenv("MIMO_API_KEY", "")
+
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Added {
+			t.Fatalf("provider %+v should not be inferred as added without a configured key", p)
+		}
+	}
+}
+
+func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash"
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil {
+		t.Fatalf("AddOfficialProviderAccess: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	p, ok := cfg.Provider("deepseek")
+	if !ok {
+		t.Fatal("deepseek provider not saved")
+	}
+	if len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
+		t.Fatalf("deepseek provider after add = %+v, want official model list", p)
+	}
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
+		t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess)
+	}
+	if cfg.DefaultModel != "deepseek/deepseek-v4-flash" {
+		t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", cfg.DefaultModel)
+	}
+}
+
+func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	view := NewApp().Settings()
+	if len(view.ProviderPresets) < 18 {
+		t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
+	}
+	got := map[string]ProviderPresetView{}
+	for _, preset := range view.ProviderPresets {
+		got[preset.ID] = preset
+	}
+	for _, curated := range config.CuratedProviderPresets() {
+		id := curated.ID
+		preset, ok := got[id]
+		if !ok {
+			t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
+		}
+		if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
+			t.Fatalf("preset %q view has missing fields: %+v", id, preset)
+		}
+	}
+}
+
+func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
+	t.Helper()
+	for _, preset := range view.ProviderPresets {
+		if preset.ID == id {
+			return preset
+		}
+	}
+	t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
+	return ProviderPresetView{}
+}
+
+func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "mimo-api"
+kind = "openai"
+base_url = "https://custom.example/v1"
+models = ["custom-model"]
+default = "custom-model"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
+		t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
+	}
+
+	var providerView *ProviderView
+	for i := range view.Providers {
+		if view.Providers[i].Name == "mimo-api" {
+			providerView = &view.Providers[i]
+			break
+		}
+	}
+	if providerView == nil {
+		t.Fatal("mimo-api provider view missing")
+	}
+	if providerView.Added {
+		t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
+	}
+}
+
+func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("missing mimo-api preset")
+	}
+	legacy := preset.Entries[0]
+	legacy.PresetID = ""
+	legacy.PresetVersion = 0
+	cfg := config.Default()
+	if err := cfg.UpsertProvider(legacy); err != nil {
+		t.Fatalf("upsert legacy provider: %v", err)
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
+		t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
+	}
+}
+
+func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("missing mimo-api preset")
+	}
+	modified := preset.Entries[0]
+	modified.BaseURL = "https://custom.example/v1"
+	cfg := config.Default()
+	if err := cfg.UpsertProvider(modified); err != nil {
+		t.Fatalf("upsert modified provider: %v", err)
+	}
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
+		t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
+	}
+}
+
+func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	cfg := config.Default()
+	stepfun, ok := config.CuratedProviderPreset("stepfun")
+	if !ok || len(stepfun.Entries) != 1 {
+		t.Fatal("missing stepfun preset")
+	}
+	stepfunEntry := stepfun.Entries[0]
+	stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
+	stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
+	if !ok || len(stepfunAnthropic.Entries) != 1 {
+		t.Fatal("missing stepfun-anthropic preset")
+	}
+	stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
+	stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
+	cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
+	cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	for _, id := range []string{"stepfun", "stepfun-anthropic"} {
+		presetView := providerPresetViewByID(t, view, id)
+		if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
+			t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView)
+		}
+	}
+
+	migrated := config.LoadForEdit(config.UserConfigPath())
+	stepfunEntryView, ok := migrated.Provider("stepfun")
+	if !ok {
+		t.Fatal("stepfun provider missing after migration")
+	}
+	if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" {
+		t.Fatalf("stepfun base_url = %q, want official URL", got)
+	}
+	stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic")
+	if !ok {
+		t.Fatal("stepfun-anthropic provider missing after migration")
+	}
+	if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" {
+		t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got)
+	}
+}
+
+func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("missing mimo-api preset")
+	}
+	similar := preset.Entries[0]
+	similar.Name = "my-mimo"
+	similar.PresetID = ""
+	similar.PresetVersion = 0
+	cfg := config.Default()
+	if err := cfg.UpsertProvider(similar); err != nil {
+		t.Fatalf("upsert similar provider: %v", err)
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
+		t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
+	}
+}
+
+func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+	os.Unsetenv("MIMO_API_KEY")
+
+	if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
+		t.Fatalf("AddProviderPresetAccess: %v", err)
+	} else if warning != "" {
+		t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
+	}
+
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	p, ok := cfg.Provider("mimo-api")
+	if !ok {
+		t.Fatal("mimo-api provider not saved")
+	}
+	if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
+		t.Fatalf("mimo-api provider after preset add = %+v", p)
+	}
+	if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
+		t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
+	}
+	if !p.NoProxy {
+		t.Fatal("mimo-api preset should save no_proxy = true")
+	}
+	if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
+		t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
+	}
+	if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
+		t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
+	}
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
+		t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read saved credentials: %v", err)
+	}
+	if !strings.Contains(string(data), "MIMO_API_KEY=sk-mimo") {
+		t.Fatalf("saved credentials missing MiMo key:\n%s", data)
+	}
+
+	view := NewApp().Settings()
+	var presetView *ProviderPresetView
+	var providerView *ProviderView
+	for i := range view.ProviderPresets {
+		if view.ProviderPresets[i].ID == "mimo-api" {
+			presetView = &view.ProviderPresets[i]
+		}
+	}
+	for i := range view.Providers {
+		if view.Providers[i].Name == "mimo-api" {
+			providerView = &view.Providers[i]
+		}
+	}
+	if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
+		t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
+	}
+	if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
+		t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
+	}
+}
+
+func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+	os.Unsetenv("MIMO_API_KEY")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
+
+	cfg := config.Default()
+	custom := config.ProviderEntry{
+		Name:      "mimo-api",
+		Kind:      "openai",
+		BaseURL:   "https://custom.example/v1",
+		Models:    []string{"custom-model"},
+		Default:   "custom-model",
+		APIKeyEnv: "MIMO_API_KEY",
+		Headers:   map[string]string{"X-Custom": "keep-me"},
+	}
+	if err := cfg.UpsertProvider(custom); err != nil {
+		t.Fatalf("upsert custom provider: %v", err)
+	}
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
+		t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
+	} else if !strings.Contains(err.Error(), "provider name(s) already exist") {
+		t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
+	} else if warning != "" {
+		t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
+	}
+
+	cfg = config.LoadForEdit(config.UserConfigPath())
+	got, ok := cfg.Provider("mimo-api")
+	if !ok {
+		t.Fatal("mimo-api provider missing after rejected add")
+	}
+	if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
+		t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read saved credentials: %v", err)
+	}
+	if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
+		t.Fatalf("credentials changed after rejected add:\n%s", data)
+	}
+}
+
+func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+	os.Unsetenv("MIMO_API_KEY")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
+
+	cfg := config.Default()
+	custom := config.ProviderEntry{
+		Name:      "mimo-api",
+		Kind:      "openai",
+		BaseURL:   "https://custom.example/v1",
+		Models:    []string{"custom-model"},
+		Default:   "custom-model",
+		APIKeyEnv: "MIMO_API_KEY",
+		Headers:   map[string]string{"X-Custom": "remove-me"},
+	}
+	if err := cfg.UpsertProvider(custom); err != nil {
+		t.Fatalf("upsert custom provider: %v", err)
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
+		t.Fatalf("ResetProviderPresetAccess: %v", err)
+	}
+
+	cfg = config.LoadForEdit(config.UserConfigPath())
+	got, ok := cfg.Provider("mimo-api")
+	if !ok {
+		t.Fatal("mimo-api provider missing after reset")
+	}
+	if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
+		t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
+	}
+	if len(got.Headers) != 0 {
+		t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
+	}
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
+		t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read saved credentials: %v", err)
+	}
+	if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
+		t.Fatalf("credentials changed after reset:\n%s", data)
+	}
+
+	presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
+		t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
+	}
+}
+
+func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
+		t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
+	} else if !strings.Contains(err.Error(), "no same-name provider exists") {
+		t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
+	}
+}
+
+func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
+	for _, preset := range config.CuratedProviderPresets() {
+		preset := preset
+		t.Run(preset.ID, func(t *testing.T) {
+			isolateDesktopUserDirs(t)
+
+			if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
+				t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
+			} else if warning != "" {
+				t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
+			}
+
+			cfg := config.LoadForEdit(config.UserConfigPath())
+			access := providerAccessSet(cfg.Desktop.ProviderAccess)
+			for _, entry := range preset.Entries {
+				got, ok := cfg.Provider(entry.Name)
+				if !ok {
+					t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
+				}
+				if !access[entry.Name] {
+					t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
+				}
+				if got.Kind != entry.Kind || got.BaseURL != entry.BaseURL || got.DefaultModel() != entry.DefaultModel() || got.APIKeyEnv != entry.APIKeyEnv || got.AuthHeader != entry.AuthHeader || got.NoProxy != entry.NoProxy {
+					t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
+				}
+				if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
+					t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
+				}
+				if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
+					t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
+				}
+				if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
+					t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
+				}
+				if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
+					t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
+				}
+			}
+
+			view := NewApp().Settings()
+			var presetView *ProviderPresetView
+			for i := range view.ProviderPresets {
+				if view.ProviderPresets[i].ID == preset.ID {
+					presetView = &view.ProviderPresets[i]
+					break
+				}
+			}
+			if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
+				t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
+			}
+		})
+	}
+}
+
+func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+
+	app := NewApp()
+	app.readyHook = func() {}
+	app.setTestCtrl(newBackgroundJobController(t, "provider-access-job"), "deepseek-flash/deepseek-v4-flash")
+
+	_, err := app.AddOfficialProviderAccess("deepseek", "sk-test")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("AddOfficialProviderAccess with background job error = %v, want active-work guard", err)
+	}
+	if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
+		t.Fatalf("provider key should not be saved after rejected add access:\n%s", data)
+	}
+}
+
+func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-flash"
+
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
+		t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess)
+	}
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Name == "deepseek" {
+			if !p.Added || !p.KeySet {
+				t.Fatalf("deepseek settings = %+v, want added and key-set", p)
+			}
+			return
+		}
+	}
+	t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
+}
+
+func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("PROXY_DEEPSEEK_KEY", "")
+	os.Unsetenv("PROXY_DEEPSEEK_KEY")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://proxy.example/v1"
+model = "deepseek-v4-flash"
+api_key_env = "PROXY_DEEPSEEK_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	if !access["deepseek-flash"] {
+		t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess)
+	}
+	if access["deepseek"] {
+		t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess)
+	}
+}
+
+func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_provider",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_provider", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	warning, err := app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
+	if err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") {
+		t.Fatalf("SetProviderKey warning = %q, want deferred rebuild warning", warning)
+	}
+	if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
+		t.Fatalf("SetProviderKey surfaced raw lease details: %v", warning)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed provider-key rebuild")
+	}
+	if tab.StartupErr != "" {
+		t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
+	}
+	if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
+		t.Fatalf("history after failed provider-key rebuild = %+v", got)
+	}
+	if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
+		t.Fatalf("provider_access should still persist longcat after key save")
+	}
+}
+
+func TestSetProviderKeyRebuildSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	// Model the async startup build still being in flight: no controller yet,
+	// a live build generation, and a cancellable build context.
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	const startupGeneration = 1
+	tab := &WorkspaceTab{
+		ID:              "tab_key_rebuild",
+		Scope:           "global",
+		SessionPath:     sessionPath,
+		model:           "old/old-model",
+		buildGeneration: startupGeneration,
+		buildCancel:     buildCancel,
+		disabledMCP:     map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(tab.releaseSessionLease)
+
+	if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	if tab.Ctrl == nil {
+		t.Fatal("provider-key rebuild did not install a controller")
+	}
+	defer tab.Ctrl.Close()
+
+	assertTabBuildSuperseded(t, app, tab, startupGeneration, buildCtx)
+}
+
+func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-custom-provider.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_custom_provider",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_custom_provider", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	warning, err := app.SaveProviderWithKey(ProviderView{
+		Name:      "proxy",
+		Kind:      "openai",
+		BaseURL:   "https://proxy.example/v1",
+		Models:    []string{"model-a", "model-b"},
+		Default:   "model-a",
+		APIKeyEnv: "PROXY_API_KEY",
+	}, "sk-proxy")
+	if err != nil {
+		t.Fatalf("SaveProviderWithKey: %v", err)
+	}
+	if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") {
+		t.Fatalf("SaveProviderWithKey warning = %q, want deferred rebuild warning", warning)
+	}
+	if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
+		t.Fatalf("SaveProviderWithKey surfaced raw lease details: %v", warning)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed provider rebuild")
+	}
+	gotCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	got, ok := gotCfg.Provider("proxy")
+	if !ok {
+		t.Fatal("custom provider was not saved")
+	}
+	if want := []string{"model-a", "model-b"}; !reflect.DeepEqual(got.ModelList(), want) {
+		t.Fatalf("custom provider models = %v, want %v", got.ModelList(), want)
+	}
+	if !providerAccessSet(gotCfg.Desktop.ProviderAccess)["proxy"] {
+		t.Fatalf("provider_access = %+v, want proxy", gotCfg.Desktop.ProviderAccess)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read credentials: %v", err)
+	}
+	if !strings.Contains(string(data), "PROXY_API_KEY=sk-proxy") {
+		t.Fatalf("provider key was not saved:\n%s", data)
+	}
+}
+
+func TestConfigChangeLeaseHeldPersistsAndDefersRefresh(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-settings.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_settings",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_settings", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth should defer lease-held refresh instead of failing: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after deferred settings rebuild")
+	}
+	got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	if got.Agent.MaxSubagentDepth != 1 {
+		t.Fatalf("saved max_subagent_depth = %d, want 1", got.Agent.MaxSubagentDepth)
+	}
+}
+
+func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	prevInterval := deferredRebuildRetryInterval
+	deferredRebuildRetryInterval = 20 * time.Millisecond
+	t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "deferred-rebuild-retry.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	released := false
+	defer func() {
+		if !released {
+			externalLease.Release()
+		}
+	}()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.enableDeferredRebuildRetry()
+	t.Cleanup(app.stopDeferredRebuildRetry)
+	tab := &WorkspaceTab{
+		ID:          "tab_deferred_retry",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_deferred_retry", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	installNoopRuntimeEvents(app, tab.sink)
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
+			c.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth: %v", err)
+	}
+	if !app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild was not scheduled while the lease is held")
+	}
+	if app.controllerForTab(tab) != oldCtrl {
+		t.Fatal("controller changed while the lease is still held")
+	}
+
+	externalLease.Release()
+	released = true
+
+	deadline := time.Now().Add(10 * time.Second)
+	for time.Now().Before(deadline) {
+		if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl {
+			break
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	if app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild is still pending after the lease was released")
+	}
+	if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
+		t.Fatalf("controller was not rebuilt after the lease release: got %p", c)
+	}
+}
+
+func TestDeferredRebuildScheduleAfterStopIsNoop(t *testing.T) {
+	app := NewApp()
+	app.stopDeferredRebuildRetry()
+	app.scheduleDeferredRebuild("tab_x", "settings")
+	if app.deferredRebuildPending("tab_x") {
+		t.Fatal("schedule after stop should not register pending work")
+	}
+}
+
+func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	prevInterval := deferredRebuildRetryInterval
+	deferredRebuildRetryInterval = 20 * time.Millisecond
+	t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "deferred-rebuild-inactive.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	released := false
+	defer func() {
+		if !released {
+			externalLease.Release()
+		}
+	}()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	otherCtrl := control.New(control.Options{Label: "other"})
+	defer otherCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.enableDeferredRebuildRetry()
+	t.Cleanup(app.stopDeferredRebuildRetry)
+	tab := &WorkspaceTab{
+		ID:          "tab_pending",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_pending", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	installNoopRuntimeEvents(app, tab.sink)
+	other := &WorkspaceTab{
+		ID:          "tab_other",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        otherCtrl,
+		sink:        &tabEventSink{tabID: "tab_other", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab, other.ID: other}
+	app.tabOrder = []string{tab.ID, other.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
+			c.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth: %v", err)
+	}
+	if !app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild was not scheduled while the lease is held")
+	}
+
+	// Focus another tab, then release the lease: the retry must not rebuild
+	// while the pending tab is inactive (rebuildSettingLocked acts on the
+	// active tab), and must not touch the focused tab's runtime either.
+	app.mu.Lock()
+	app.activeTabID = other.ID
+	app.mu.Unlock()
+	externalLease.Release()
+	released = true
+
+	time.Sleep(150 * time.Millisecond)
+	if !app.deferredRebuildPending(tab.ID) {
+		t.Fatal("pending entry was consumed while its tab was inactive")
+	}
+	if app.controllerForTab(tab) != oldCtrl {
+		t.Fatal("inactive pending tab was rebuilt")
+	}
+	if app.controllerForTab(other) != otherCtrl {
+		t.Fatal("focused tab was rebuilt by another tab's deferred retry")
+	}
+
+	// Switch back: the retry should now refresh the pending tab.
+	app.mu.Lock()
+	app.activeTabID = tab.ID
+	app.mu.Unlock()
+
+	deadline := time.Now().Add(10 * time.Second)
+	for time.Now().Before(deadline) {
+		if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl {
+			break
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	if app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild still pending after its tab became active again")
+	}
+	if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
+		t.Fatalf("controller was not rebuilt after tab reactivation: got %p", c)
+	}
+}
+
+func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:             "old",
+		Kind:             "openai",
+		BaseURL:          "https://example.invalid/v1",
+		Model:            "old-model",
+		APIKeyEnv:        "OLD_MODEL_KEY",
+		SupportedEfforts: []string{"low", "max"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-effort-switch.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	released := false
+	defer func() {
+		if !released {
+			externalLease.Release()
+		}
+	}()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_effort",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_effort", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
+			c.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	err = app.SetEffortForTab(tab.ID, "max")
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("SetEffortForTab err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
+		t.Fatalf("SetEffortForTab surfaced raw lease details: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatal("tab controller changed after failed effort switch")
+	}
+
+	// The failed switch must leave the old runtime alive: after the other
+	// window releases the lease, retrying from the same tab has to succeed.
+	// (The old code closed the old controller before acquiring the lease, so
+	// this retry died on a snapshot of a closed session.)
+	externalLease.Release()
+	released = true
+	if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
+		t.Fatalf("SetEffortForTab retry after lease release: %v", err)
+	}
+	if tab.Ctrl == oldCtrl {
+		t.Fatal("retry did not rebuild the controller")
+	}
+}
+
+func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:             "old",
+		Kind:             "openai",
+		BaseURL:          "https://example.invalid/v1",
+		Model:            "old-model",
+		APIKeyEnv:        "OLD_MODEL_KEY",
+		SupportedEfforts: []string{"low", "max"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl")
+	disk := agent.NewSession("old system prompt")
+	disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := disk.Save(recoveryPath); err != nil {
+		t.Fatalf("save recovery branch: %v", err)
+	}
+	meta, ok, err := agent.LoadBranchMeta(recoveryPath)
+	if err != nil || !ok {
+		t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
+	}
+	meta.Recovered = true
+	meta.ParentID = "effort-switch-conflict"
+	meta.RecoveryReason = "snapshot conflict"
+	meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
+	if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil {
+		t.Fatalf("SaveBranchMeta: %v", err)
+	}
+
+	stale := agent.NewSession("old system prompt")
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
+	tab := &WorkspaceTab{
+		ID:          "tab_depth_cap_effort",
+		Scope:       "global",
+		SessionPath: recoveryPath,
+		Ready:       true,
+		model:       "old/old-model",
+		disabledMCP: map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	oldCtrl := control.New(control.Options{
+		Executor:            oldExec,
+		SessionDir:          dir,
+		SessionPath:         recoveryPath,
+		Label:               "old",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	tab.Ctrl = oldCtrl
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+	stale.IncrementRewrite()
+
+	if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
+		t.Fatalf("SetEffortForTab: %v", err)
+	}
+	if got := tab.Ctrl.SessionPath(); got != recoveryPath {
+		t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
+	}
+	if got := tab.currentSessionPath(); got != recoveryPath {
+		t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
+		t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
+	}
+	matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
+	}
+
+	lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
+	if len(lines) != 1 {
+		t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
+	}
+	if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
+		t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
+	}
+	if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
+		t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
+	}
+
+	if err := tab.Ctrl.Snapshot(); err != nil {
+		t.Fatalf("Snapshot after effort switch recovery: %v", err)
+	}
+	afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
+	if len(afterLines) != len(lines) {
+		t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines)
+	}
+	matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches after snapshot: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
+	}
+}
+
+func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[desktop]
+language = "zh"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
+		t.Fatalf("AddOfficialProviderAccess: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	p, ok := cfg.Provider("deepseek")
+	if !ok {
+		t.Fatal("deepseek provider not saved")
+	}
+	flash := p.Prices["deepseek-v4-flash"]
+	pro := p.Prices["deepseek-v4-pro"]
+	if flash == nil || flash.Output != 2 || flash.Currency != "¥" {
+		t.Fatalf("flash price = %+v, want CNY preset", flash)
+	}
+	if pro == nil || pro.Output != 6 || pro.Currency != "¥" {
+		t.Fatalf("pro price = %+v, want CNY preset", pro)
+	}
+}
+
+func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash/deepseek-v4-pro"
+
+[desktop]
+provider_access = ["deepseek-flash", "mimo-pro"]
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+
+[[providers]]
+name = "mimo-pro"
+kind = "openai"
+base_url = "https://token-plan-cn.xiaomimimo.com/v1"
+model = "mimo-v2.5-pro"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if err := NewApp().RemoveProviderAccess("deepseek"); err != nil {
+		t.Fatalf("RemoveProviderAccess: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	if access["deepseek"] || !access["mimo-pro"] {
+		t.Fatalf("provider_access = %+v, want only mimo-pro", cfg.Desktop.ProviderAccess)
+	}
+	if cfg.DefaultModel != "mimo-pro/mimo-v2.5-pro" {
+		t.Fatalf("default_model = %q, want mimo-pro/mimo-v2.5-pro", cfg.DefaultModel)
+	}
+}
+
+func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
+	deepseek, _ := cfg.Provider("deepseek-flash")
+	deepseek.Model = ""
+	deepseek.Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
+	deepseek.Default = "deepseek-v4-flash"
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{
+		"deepseek/deepseek-v4-flash",
+		"deepseek/deepseek-v4-pro",
+		"mimo-pro/mimo-v2.5-pro",
+		"mimo-pro/mimo-v2.5",
+	} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+	for _, hidden := range []string{
+		"deepseek-pro/deepseek-v4-pro",
+		"mimo-flash/mimo-v2.5",
+	} {
+		if refs[hidden] {
+			t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
+		}
+	}
+	if len(models) != 4 {
+		t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
+	}
+}
+
+func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "local/model-a"
+
+[desktop]
+provider_access = ["local"]
+
+[[providers]]
+name = "local"
+kind = "openai"
+base_url = "http://127.0.0.1:23333/v1"
+models = ["model-a", "model-b"]
+default = "model-a"
+api_key_env = "LOCAL_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{"local/model-a", "local/model-b"} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+	if len(models) != 2 {
+		t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
+	}
+}
+
+func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "local/model-a"
+
+[desktop]
+provider_access = ["local"]
+
+[[providers]]
+name = "local"
+kind = "openai"
+base_url = "http://127.0.0.1:23333/v1"
+models = ["model-a", "model-b"]
+default = "model-a"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{"local/model-a", "local/model-b"} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+}
+
+func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "local/model-a"
+
+[desktop]
+provider_access = ["local"]
+
+[[providers]]
+name = "local"
+kind = "openai"
+base_url = "http://127.0.0.1:23333/v1"
+models = ["model-a", "model-b"]
+default = "model-a"
+api_key_env = "LOCAL_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{"local/model-a", "local/model-b"} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+}
+
+func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("mimo-api preset missing")
+	}
+	if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
+		t.Fatalf("upsert mimo-api preset: %v", err)
+	}
+	cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{
+		"mimo-api/mimo-v2.5-pro",
+		"mimo-api/mimo-v2.5",
+	} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+	if len(models) != 2 {
+		t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
+	}
+}
+
+func TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	userCfg := config.Default()
+	userCfg.DefaultModel = "mimo-pro/mimo-v2.5-pro"
+	userCfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	projectRoot := t.TempDir()
+	projectConfig := `default_model = "deepseek-flash/deepseek-v4-flash"
+
+[desktop]
+provider_access = ["deepseek-flash"]
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+model = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`
+	if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.activeTabID = tab.ID
+
+	models := app.ModelsForTab(tab.ID)
+	refs := modelRefsFromView(models)
+	for _, want := range []string{
+		"deepseek/deepseek-v4-flash",
+		"mimo-pro/mimo-v2.5-pro",
+	} {
+		if !refs[want] {
+			t.Fatalf("ModelsForTab refs = %+v, missing %s", models, want)
+		}
+	}
+}
+
+func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek-flash"}
+	cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "other", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "other-model"})
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err := app.SetModelForTab(tab.ID, "other/other-model")
+	if err == nil || !strings.Contains(err.Error(), "not available") {
+		t.Fatalf("SetModelForTab hidden provider error = %v, want not available", err)
+	}
+}
+
+func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+	if err := os.MkdirAll(config.MemoryUserDir(), 0o755); err != nil {
+		t.Fatalf("mkdir memory dir: %v", err)
+	}
+	const freshRule = "Fresh global AGENTS rule for model switch"
+	if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "AGENTS.md"), []byte(freshRule), 0o644); err != nil {
+		t.Fatalf("write global AGENTS.md: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	oldSession := agent.NewSession("old system prompt without memory")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldPath := filepath.Join(dir, "old.jsonl")
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 {
+		t.Fatalf("history length = %d, want system + user", len(history))
+	}
+	if history[0].Role != provider.RoleSystem {
+		t.Fatalf("first message role = %s, want system", history[0].Role)
+	}
+	if !strings.Contains(history[0].Content, freshRule) {
+		t.Fatalf("refreshed system prompt missing global AGENTS rule:\n%s", history[0].Content)
+	}
+	if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
+		t.Fatalf("carried user message changed: %+v", history[1])
+	}
+}
+
+func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
+	current := agent.NewSession("old system prompt")
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := current.Save(originalPath); err != nil {
+		t.Fatalf("save current session: %v", err)
+	}
+
+	stale := agent.NewSession("old system prompt")
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
+	tab := &WorkspaceTab{
+		ID:          "tab_recovery_model",
+		Scope:       "global",
+		SessionPath: originalPath,
+		Ready:       true,
+		model:       "old/old-model",
+		disabledMCP: map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	oldCtrl := control.New(control.Options{
+		Executor:            oldExec,
+		SessionDir:          dir,
+		SessionPath:         originalPath,
+		Label:               "old",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	tab.Ctrl = oldCtrl
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	recoveryPath := tab.Ctrl.SessionPath()
+	if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
+		t.Fatalf("model switch session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
+	}
+	if got := tab.currentSessionPath(); got != recoveryPath {
+		t.Fatalf("tab current session path = %q, want recovery path %q", got, recoveryPath)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
+		t.Fatalf("tab lease path = %q, want recovery path %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
+	}
+
+	matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after model switch = %v, want only %q", matches, recoveryPath)
+	}
+	if err := tab.Ctrl.Snapshot(); err != nil {
+		t.Fatalf("Snapshot after model switch recovery: %v", err)
+	}
+	matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches after snapshot: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
+	}
+}
+
+func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldPath := filepath.Join(dir, "leased-model-switch.jsonl")
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := tab.ensureSessionLease(oldPath); err != nil {
+		t.Fatalf("ensureSessionLease: %v", err)
+	}
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("tab controller was not rebuilt")
+	}
+	if got := tab.model; got != "new/new-model" {
+		t.Fatalf("tab model = %q, want new/new-model", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(oldPath) {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), oldPath)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
+		t.Fatalf("carried history = %+v, want original user message", history)
+	}
+}
+
+func TestSetModelForTabWaitsForConcurrentBlankSessionLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := desktopSessionDir(globalTabWorkspaceRoot())
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	path := filepath.Join(dir, "blank-model-switch-race.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write blank session: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:            "tab_blank_race",
+		Scope:         "global",
+		WorkspaceRoot: globalTabWorkspaceRoot(),
+		SessionPath:   path,
+		Ready:         true,
+		model:         "old/old-model",
+		sink:          &tabEventSink{tabID: "tab_blank_race", app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	acquired := make(chan struct{})
+	releaseHook := make(chan struct{})
+	var once sync.Once
+	sessionLeaseAcquireHookForTest = func() {
+		once.Do(func() {
+			close(acquired)
+			<-releaseHook
+		})
+	}
+	t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
+
+	buildErr := make(chan error, 1)
+	go func() {
+		buildErr <- tab.ensureSessionLease(path)
+	}()
+
+	select {
+	case <-acquired:
+	case err := <-buildErr:
+		t.Fatalf("background lease acquire returned before hook: %v", err)
+	case <-time.After(2 * time.Second):
+		t.Fatal("background lease acquire did not start")
+	}
+
+	switchErr := make(chan error, 1)
+	go func() {
+		switchErr <- app.SetModelForTab(tab.ID, "new/new-model")
+	}()
+
+	select {
+	case err := <-switchErr:
+		t.Fatalf("SetModelForTab returned before concurrent lease was bound: %v", err)
+	case <-time.After(50 * time.Millisecond):
+	}
+
+	close(releaseHook)
+	if err := <-buildErr; err != nil {
+		t.Fatalf("background ensureSessionLease: %v", err)
+	}
+	if err := <-switchErr; err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	if tab.Ctrl == nil {
+		t.Fatal("model switch did not build a controller")
+	}
+	if got := tab.model; got != "new/new-model" {
+		t.Fatalf("tab model = %q, want new/new-model", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
+	}
+}
+
+func TestSetModelForTabLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	oldPath := filepath.Join(dir, "externally-leased-model-switch.jsonl")
+	if err := os.WriteFile(oldPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(oldPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err = app.SetModelForTab(tab.ID, "new/new-model")
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("SetModelForTab err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if strings.Contains(err.Error(), oldPath) || strings.Contains(err.Error(), "held by") {
+		t.Fatalf("SetModelForTab surfaced raw lease details: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed switch")
+	}
+	if got := tab.model; got != "old/old-model" {
+		t.Fatalf("tab model = %q, want old/old-model", got)
+	}
+	info, err := os.Stat(oldPath)
+	if err != nil {
+		t.Fatalf("stat session: %v", err)
+	}
+	if info.Size() != 0 {
+		t.Fatalf("session file size = %d, want unchanged empty file", info.Size())
+	}
+}
+
+func TestSetModelForTabReattachesDetachedRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := desktopSessionDir(globalTabWorkspaceRoot())
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "detached-model-switch.jsonl")
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello from detached"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
+	lease, err := agent.TryAcquireSessionLease(path)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	key := sessionRuntimeKey(path)
+	detached := &WorkspaceTab{
+		ID:             detachedRuntimeTabID(key),
+		Scope:          "global",
+		SessionPath:    path,
+		Ctrl:           oldCtrl,
+		Ready:          true,
+		model:          "old/old-model",
+		sessionLease:   lease,
+		disabledMCP:    map[string]ServerView{},
+		SharedHostKey:  "detached-host",
+		ActivityStatus: "",
+	}
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		SessionPath: path,
+		Ready:       true,
+		model:       "old/old-model",
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.detachedSessions = map[string]*WorkspaceTab{key: detached}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+		if detached.sessionLease != nil {
+			detached.releaseSessionLease()
+		}
+	})
+
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	if _, ok := app.detachedSessions[key]; ok {
+		t.Fatal("detached runtime was not consumed")
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("tab controller was not rebuilt from detached runtime")
+	}
+	if got := tab.model; got != "new/new-model" {
+		t.Fatalf("tab model = %q, want new/new-model", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != key {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 || history[1].Content != "hello from detached" {
+		t.Fatalf("carried history = %+v, want detached user message", history)
+	}
+}
+
+type staleWorkspaceBindingFixture struct {
+	app          *App
+	tab          *WorkspaceTab
+	oldCtrl      control.SessionAPI
+	projectA     string
+	sessionDirA  string
+	sessionPathA string
+}
+
+func newStaleWorkspaceBindingFixture(t *testing.T, suffix string) staleWorkspaceBindingFixture {
+	return newStaleWorkspaceBindingFixtureWithLayout(t, suffix, "")
+}
+
+func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle string) staleWorkspaceBindingFixture {
+	t.Helper()
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
+
+	// Steers and idle-steer fallbacks start real provider turns (they are no
+	// longer command-interpreted), so the fixture's provider must complete
+	// instantly instead of pointing at an unreachable host — otherwise every
+	// steer leaves the controller running for the rest of the test.
+	providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+		w.WriteHeader(http.StatusOK)
+		_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
+	}))
+	t.Cleanup(providerStub.Close)
+
+	cfg := config.Default()
+	cfg.DefaultModel = "test/test-model"
+	cfg.Desktop.ProviderAccess = []string{"test"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "test", Kind: "openai", BaseURL: providerStub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
+	}
+	if strings.TrimSpace(layoutStyle) != "" {
+		if err := cfg.SetDesktopLayoutStyle(layoutStyle); err != nil {
+			t.Fatalf("set desktop layout style: %v", err)
+		}
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	topicID := "topic_" + suffix
+	topicTitle := "Rebuild workspace " + suffix
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
+	sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "carry me"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{
+		Executor:      oldExec,
+		SessionDir:    sessionDirB,
+		SessionPath:   sessionPathB,
+		Label:         "test/test-model",
+		ModelRef:      "test/test-model",
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+
+	app := NewApp()
+	app.readyHook = func() {}
+	tab := &WorkspaceTab{
+		ID:            "tab_stale_workspace_" + suffix,
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       topicID,
+		TopicTitle:    topicTitle,
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		model:         "test/test-model",
+		Ctrl:          oldCtrl,
+		sink:          &tabEventSink{tabID: "tab_stale_workspace_" + suffix, app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	return staleWorkspaceBindingFixture{
+		app:          app,
+		tab:          tab,
+		oldCtrl:      oldCtrl,
+		projectA:     projectA,
+		sessionDirA:  sessionDirA,
+		sessionPathA: sessionPathA,
+	}
+}
+
+func assertTabRebuiltToPinnedWorkspace(t *testing.T, f staleWorkspaceBindingFixture) {
+	t.Helper()
+	if f.tab.Ctrl == nil {
+		t.Fatal("controller was not rebuilt")
+	}
+	if f.tab.Ctrl == f.oldCtrl {
+		t.Fatal("stale controller was reused")
+	}
+	if got := normalizeProjectRoot(f.tab.WorkspaceRoot); got != normalizeProjectRoot(f.projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA))
+	}
+	if got := normalizeProjectRoot(f.tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(f.projectA) {
+		t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA))
+	}
+	if !sameDesktopPath(f.tab.Ctrl.SessionDir(), f.sessionDirA) {
+		t.Fatalf("controller session dir = %q, want %q", f.tab.Ctrl.SessionDir(), f.sessionDirA)
+	}
+	if !sameDesktopPath(f.tab.Ctrl.SessionPath(), f.sessionPathA) {
+		t.Fatalf("controller session path = %q, want %q", f.tab.Ctrl.SessionPath(), f.sessionPathA)
+	}
+}
+
+type blockingSnapshotCtrl struct {
+	control.SessionAPI
+
+	firstSnapshotStarted  chan struct{}
+	secondSnapshotStarted chan struct{}
+	releaseSnapshot       chan struct{}
+	firstOnce             sync.Once
+	secondOnce            sync.Once
+	snapshotCount         atomic.Int32
+	closeCount            atomic.Int32
+}
+
+func newBlockingSnapshotCtrl(ctrl control.SessionAPI) *blockingSnapshotCtrl {
+	return &blockingSnapshotCtrl{
+		SessionAPI:            ctrl,
+		firstSnapshotStarted:  make(chan struct{}),
+		secondSnapshotStarted: make(chan struct{}),
+		releaseSnapshot:       make(chan struct{}),
+	}
+}
+
+func (c *blockingSnapshotCtrl) Snapshot() error {
+	count := c.snapshotCount.Add(1)
+	switch count {
+	case 1:
+		c.firstOnce.Do(func() { close(c.firstSnapshotStarted) })
+	case 2:
+		c.secondOnce.Do(func() { close(c.secondSnapshotStarted) })
+	}
+	<-c.releaseSnapshot
+	if c.SessionAPI == nil {
+		return nil
+	}
+	return c.SessionAPI.Snapshot()
+}
+
+func (c *blockingSnapshotCtrl) Close() {
+	c.closeCount.Add(1)
+	if c.SessionAPI != nil {
+		c.SessionAPI.Close()
+	}
+}
+
+func (f *staleWorkspaceBindingFixture) installBlockingSnapshotController() *blockingSnapshotCtrl {
+	ctrl := newBlockingSnapshotCtrl(f.tab.Ctrl)
+	f.tab.Ctrl = ctrl
+	f.oldCtrl = ctrl
+	return ctrl
+}
+
+func TestEnsureTabControllerWorkspaceRebuildsStaleWorkspace(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "rebuild_workspace")
+
+	if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
+		t.Fatalf("ensureTabControllerWorkspace: %v", err)
+	}
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestEnsureTabControllerWorkspaceWarnsWhenPinnedSessionSwitchesWorkspace(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "warn_workspace_switch")
+	events := make(chan event.Event, 8)
+	f.tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
+		events <- e
+	}))
+
+	if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
+		t.Fatalf("ensureTabControllerWorkspace: %v", err)
+	}
+	assertTabRebuiltToPinnedWorkspace(t, f)
+
+	deadline := time.After(2 * time.Second)
+	for {
+		select {
+		case e := <-events:
+			if e.Kind == event.Notice &&
+				e.Level == event.LevelWarn &&
+				strings.Contains(e.Text, f.projectA) &&
+				strings.Contains(e.Text, "switched tab") {
+				return
+			}
+		case <-deadline:
+			t.Fatal("did not receive workspace switch warning notice")
+		}
+	}
+}
+
+func TestSteerForTabReconcilesStaleWorkspaceBeforeIdleFallback(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback")
+
+	// The idle fallback submits the steer text verbatim as a provider turn
+	// (steers are never command-interpreted); the fixture's provider stub
+	// completes it instantly.
+	if err := f.app.SteerForTab(f.tab.ID, "steer guidance"); err != nil {
+		t.Fatalf("SteerForTab: %v", err)
+	}
+	waitNotRunning(t, f.tab.Ctrl)
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestCompactReconcilesStaleWorkspaceBeforeCompaction(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "compact")
+
+	if err := f.app.Compact(); err != nil {
+		t.Fatalf("Compact: %v", err)
+	}
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestEffortCommandUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OWNER_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "STALE_MODEL_KEY", "sk-test")
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	ownerConfig := `default_model = "owner/owner-model"
+[[providers]]
+name = "owner"
+kind = "openai"
+base_url = "https://owner.example.invalid/v1"
+model = "owner-model"
+api_key_env = "OWNER_MODEL_KEY"
+supported_efforts = ["max"]
+default_effort = "max"
+`
+	if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	staleConfig := `default_model = "stale/stale-model"
+[[providers]]
+name = "stale"
+kind = "openai"
+base_url = "https://stale.example.invalid/v1"
+model = "stale-model"
+api_key_env = "STALE_MODEL_KEY"
+reasoning_protocol = "none"
+`
+	if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	topicID := "topic_effort_owner"
+	topicTitle := "Effort owner"
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
+	oldCtrl := control.New(control.Options{
+		SessionDir:    sessionDirB,
+		SessionPath:   filepath.Join(sessionDirB, "wrong.jsonl"),
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+
+	app := NewApp()
+	app.readyHook = func() {}
+	tab := &WorkspaceTab{
+		ID:            "tab_stale_effort",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       topicID,
+		TopicTitle:    topicTitle,
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		Ctrl:          oldCtrl,
+		sink:          &tabEventSink{tabID: "tab_stale_effort", app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	if err := app.SubmitToTab(tab.ID, "/effort max"); err != nil {
+		t.Fatalf("SubmitToTab(/effort max): %v", err)
+	}
+	waitNotRunning(t, tab.Ctrl)
+	if tab.effort == nil || *tab.effort != "max" {
+		t.Fatalf("tab effort = %#v, want max from pinned project A provider", tab.effort)
+	}
+	if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
+	}
+	if got := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
+	}
+}
+
+func TestClassicLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
+	runQuickClickWorkspaceReconcileTest(t, "classic")
+}
+
+func TestWorkbenchLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
+	runQuickClickWorkspaceReconcileTest(t, "workbench")
+}
+
+func TestCreationLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
+	runQuickClickWorkspaceReconcileTest(t, "creation")
+}
+
+func runQuickClickWorkspaceReconcileTest(t *testing.T, layoutStyle string) {
+	t.Helper()
+	f := newStaleWorkspaceBindingFixtureWithLayout(t, "quick_click_"+layoutStyle, layoutStyle)
+	if got, want := f.app.singleSurfaceLayoutEnabled(), singleSurfaceLayoutStyle(layoutStyle); got != want {
+		t.Fatalf("singleSurfaceLayoutEnabled(%q) = %v, want %v", layoutStyle, got, want)
+	}
+	blockingCtrl := f.installBlockingSnapshotController()
+
+	type quickAction struct {
+		name string
+		run  func() error
+	}
+	actions := []quickAction{
+		{name: "submit", run: func() error { return f.app.SubmitToTab(f.tab.ID, "/unknown-command") }},
+		{name: "steer", run: func() error { return f.app.SteerForTab(f.tab.ID, "steer guidance") }},
+		{name: "compact", run: func() error { return f.app.Compact() }},
+		{name: "submit-display", run: func() error { return f.app.SubmitDisplayToTab(f.tab.ID, "/unknown display", "/unknown-command") }},
+	}
+
+	start := make(chan struct{})
+	ready := make(chan struct{}, len(actions))
+	errs := make(chan error, len(actions))
+	var wg sync.WaitGroup
+	for _, action := range actions {
+		action := action
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			ready <- struct{}{}
+			<-start
+			if err := action.run(); err != nil {
+				errs <- fmt.Errorf("%s: %w", action.name, err)
+			}
+		}()
+	}
+	for range actions {
+		<-ready
+	}
+	close(start)
+
+	select {
+	case <-blockingCtrl.firstSnapshotStarted:
+	case <-time.After(time.Second):
+		t.Fatal("timed out waiting for first stale controller snapshot")
+	}
+	select {
+	case <-blockingCtrl.secondSnapshotStarted:
+		t.Fatal("workspace rebuild was not serialized: second stale snapshot started before the first rebuild finished")
+	case <-time.After(75 * time.Millisecond):
+	}
+	close(blockingCtrl.releaseSnapshot)
+	wg.Wait()
+	close(errs)
+	for err := range errs {
+		// The steer action holds a real provider turn now, so racing quick
+		// clicks may legitimately observe a busy controller. This test
+		// asserts workspace-rebuild serialization, not that every
+		// concurrent action wins the turn.
+		if strings.Contains(err.Error(), "turn already running") ||
+			strings.Contains(err.Error(), "cannot compact while a turn is running") {
+			continue
+		}
+		t.Error(err)
+	}
+	if t.Failed() {
+		return
+	}
+	if got := blockingCtrl.snapshotCount.Load(); got != 1 {
+		t.Fatalf("stale snapshot count = %d, want 1", got)
+	}
+	if got := blockingCtrl.closeCount.Load(); got != 1 {
+		t.Fatalf("stale close count = %d, want 1", got)
+	}
+	waitNotRunning(t, f.tab.Ctrl)
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_project_a", "Project A topic", projectA, "project A prompt", time.Now())
+	sessionPathB := writeTopicSessionWithPrompt(t, sessionDirB, "project-b.jsonl", "topic_project_b", "Project B topic", projectB, "project B prompt", time.Now().Add(time.Minute))
+
+	app := NewApp()
+	oldCtrl := control.New(control.Options{
+		SessionDir:    sessionDirB,
+		SessionPath:   sessionPathB,
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+	tab := &WorkspaceTab{
+		ID:            "tab_stale_runtime_dir",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       "topic_project_a",
+		TopicTitle:    "Project A topic",
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		Ctrl:          oldCtrl,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(oldCtrl.Close)
+
+	sessions := app.ListSessions()
+	if len(sessions) == 0 {
+		t.Fatal("ListSessions() returned no sessions")
+	}
+	if filepath.Clean(sessions[0].Path) != filepath.Clean(sessionPathA) {
+		t.Fatalf("ListSessions()[0].Path = %q, want pinned project A session %q", sessions[0].Path, sessionPathA)
+	}
+	for _, item := range sessions {
+		if filepath.Clean(item.Path) == filepath.Clean(sessionPathB) {
+			t.Fatalf("ListSessions() included stale project B runtime session: %+v", sessions)
+		}
+	}
+	if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
+	}
+}
+
+func TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+
+	cfg := config.Default()
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err := app.SetDefaultModel("mimo-api/mimo-v2.5-pro")
+	if err == nil || !strings.Contains(err.Error(), "has no key") {
+		t.Fatalf("SetDefaultModel no-key error = %v, want has no key", err)
+	}
+	if tab.model != "deepseek-flash/deepseek-v4-flash" {
+		t.Fatalf("tab model after failed default change = %q, want previous", tab.model)
+	}
+}
+
+func TestSaveProviderPersistsReasoningProtocol(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	if err := app.SaveProvider(ProviderView{
+		Name:              "deepseek-proxy",
+		Kind:              "openai",
+		BaseURL:           "https://proxy.example.com/v1",
+		Models:            []string{"deepseek-v4-flash"},
+		Default:           "deepseek-v4-flash",
+		APIKeyEnv:         "DEEPSEEK_PROXY_KEY",
+		ReasoningProtocol: "none",
+		SupportedEfforts:  []string{"high", "max"},
+		DefaultEffort:     "max",
+	}); err != nil {
+		t.Fatalf("SaveProvider: %v", err)
+	}
+
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	got, ok := cfg.Provider("deepseek-proxy")
+	if !ok {
+		t.Fatal("saved provider not found")
+	}
+	if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" {
+		t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got)
+	}
+
+	view := app.Settings()
+	for _, p := range view.Providers {
+		if p.Name == "deepseek-proxy" {
+			if p.ReasoningProtocol != "none" {
+				t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol)
+			}
+			return
+		}
+	}
+	t.Fatalf("Settings() missing saved provider: %+v", view.Providers)
+}
+
+func TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-a/model-a2"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	cfg.Agent.PlannerModel = "prov-a"
+	cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	ctrl := control.New(control.Options{Label: "old"})
+	defer ctrl.Close()
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ctrl: ctrl, Label: "prov-a/model-a1", Ready: true, model: "prov-a/model-a1"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.DeleteProvider("prov-a"); err != nil {
+		t.Fatalf("DeleteProvider: %v", err)
+	}
+
+	got := config.LoadForEdit(config.UserConfigPath())
+	if _, ok := got.Provider("prov-a"); ok {
+		t.Fatal("prov-a should be removed")
+	}
+	if got.DefaultModel != "prov-b" || got.Agent.PlannerModel != "prov-b" {
+		t.Fatalf("model refs after delete = default:%q planner:%q, want prov-b", got.DefaultModel, got.Agent.PlannerModel)
+	}
+	if providerAccessSet(got.Desktop.ProviderAccess)["prov-a"] {
+		t.Fatalf("provider access still contains prov-a: %+v", got.Desktop.ProviderAccess)
+	}
+	if tab.model != "prov-b/model-b1" || tab.Label != "prov-b/model-b1" {
+		t.Fatalf("tab model after delete = model:%q label:%q, want prov-b/model-b1", tab.model, tab.Label)
+	}
+	if tab.Ctrl != nil {
+		t.Fatal("tab controller should be closed and cleared when retargeted without a running app context")
+	}
+}
+
+// assertTabBuildSuperseded checks that the startup build registered before the
+// mutation (generation) can no longer install its controller and that its
+// build context was cancelled.
+func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
+	t.Helper()
+	app.mu.Lock()
+	superseded := app.tabBuildSupersededLocked(tab, generation)
+	app.mu.Unlock()
+	if !superseded {
+		t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
+	}
+	select {
+	case <-buildCtx.Done():
+	default:
+		t.Fatal("in-flight startup build context was not cancelled")
+	}
+	if tab.buildCancel != nil {
+		t.Fatal("build cancel was not cleared")
+	}
+}
+
+func TestDeleteProviderSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-b/model-b1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	// Model the async startup build still being in flight for the affected
+	// tab: no controller yet, a live generation, a cancellable build context.
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	tab := &WorkspaceTab{
+		ID:              "tab_a",
+		Scope:           "global",
+		model:           "prov-a/model-a1",
+		buildGeneration: 1,
+		buildCancel:     buildCancel,
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.DeleteProvider("prov-a"); err != nil {
+		t.Fatalf("DeleteProvider: %v", err)
+	}
+	assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
+	if tab.model != "prov-b/model-b1" {
+		t.Fatalf("tab model after delete = %q, want prov-b/model-b1", tab.model)
+	}
+}
+
+func TestRemoveBuiltInProviderAccessSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-b/model-b1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	tab := &WorkspaceTab{
+		ID:              "tab_ds",
+		Scope:           "global",
+		model:           "deepseek/deepseek-chat",
+		buildGeneration: 1,
+		buildCancel:     buildCancel,
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.RemoveProviderAccess("deepseek"); err != nil {
+		t.Fatalf("RemoveProviderAccess: %v", err)
+	}
+	assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
+	if tab.model != "prov-b/model-b1" {
+		t.Fatalf("tab model after access removal = %q, want prov-b/model-b1", tab.model)
+	}
+}
+
+func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+
+	oldSession := agent.NewSession("old system prompt")
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	// A runtime is attached while an older async build is still in flight
+	// (e.g. attached via topic activation); destroying the session must
+	// invalidate that build so it cannot resurrect the destroyed session.
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	tab := &WorkspaceTab{
+		ID:              "tab_clear",
+		Scope:           "global",
+		SessionPath:     sessionPath,
+		model:           "old/old-model",
+		Ready:           true,
+		Ctrl:            oldCtrl,
+		buildGeneration: 1,
+		buildCancel:     buildCancel,
+		disabledMCP:     map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(tab.releaseSessionLease)
+
+	if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
+		t.Fatalf("clearActiveSessionRuntime: %v", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
+	}
+	defer tab.Ctrl.Close()
+	assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
+}
+
+func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+
+	oldSession := agent.NewSession("old system prompt")
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	tab := &WorkspaceTab{
+		ID:          "tab_replaced",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		model:       "old/old-model",
+		Ready:       true,
+		Ctrl:        oldCtrl,
+		disabledMCP: map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	// The tab entry now points at a replacement struct (the tab was closed and
+	// reopened while the clear ran off-lock), so the swap must not apply.
+	replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(tab.releaseSessionLease)
+
+	err := app.clearActiveSessionRuntime(tab, oldCtrl)
+	if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
+		t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
+	}
+	if replacement.Ctrl != nil {
+		t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
+	}
+	if key := tab.sessionLeaseRuntimeKey(); key != "" {
+		t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
+	}
+	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
+		t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
+	}
+}
+
+func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-a/model-a1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
+	ctrl := app.activeCtrl()
+	ctrl.Submit("work")
+	<-runner.started
+
+	err := app.DeleteProvider("prov-a")
+	if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
+		t.Fatalf("DeleteProvider while running error = %v, want finish/cancel guard", err)
+	}
+	if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
+		t.Fatal("provider should remain after rejected deletion")
+	}
+
+	close(runner.release)
+	waitNotRunning(t, ctrl)
+	ctrl.Close()
+}
+
+func TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-a/model-a1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "provider-job.jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	defer ctrl.Close()
+	app := NewApp()
+	app.setTestCtrl(ctrl, "prov-a/model-a1")
+	jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) {
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+
+	err := app.DeleteProvider("prov-a")
+	if err == nil || !strings.Contains(err.Error(), "active work") {
+		t.Fatalf("DeleteProvider with background job error = %v, want active-work guard", err)
+	}
+	if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
+		t.Fatal("provider should remain after rejected deletion")
+	}
+}
+
+func TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-b/model-b1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(newBackgroundJobController(t, "provider-unaffected-job"), "prov-b/model-b1")
+
+	err := app.DeleteProvider("prov-a")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("DeleteProvider with unaffected background job error = %v, want active-work guard", err)
+	}
+	if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
+		t.Fatal("unaffected provider should remain after rejected deletion")
+	}
+}
+
+func TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "mimo-pro/mimo-v2.5-pro"
+
+[desktop]
+provider_access = ["deepseek-flash", "mimo-pro"]
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+
+[[providers]]
+name = "mimo-pro"
+kind = "openai"
+base_url = "https://token-plan-cn.xiaomimimo.com/v1"
+model = "mimo-v2.5-pro"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(newBackgroundJobController(t, "provider-access-unaffected-job"), "mimo-token-plan/mimo-v2.5-pro")
+
+	err := app.RemoveProviderAccess("deepseek")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("RemoveProviderAccess with background job error = %v, want active-work guard", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	if !access["deepseek"] && !access["deepseek-flash"] {
+		t.Fatalf("provider_access should still contain deepseek after rejected removal: %+v", cfg.Desktop.ProviderAccess)
+	}
+}
+
+func TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(newBackgroundJobController(t, "connect-key-job"), "deepseek-flash/deepseek-v4-flash")
+
+	_, err := app.ConnectKey("sk-test")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("ConnectKey with background job error = %v, want active-work guard", err)
+	}
+	if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
+		t.Fatalf("onboarding key should not be saved after rejected connect:\n%s", data)
+	}
+}
+
+func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv(onboardingKeyEnv, "")
+	os.Unsetenv(onboardingKeyEnv)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	oldFetch := connectKeyBalanceFetch
+	connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
+		return &billing.Balance{Available: true}, nil
+	}
+	t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_connect",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_connect", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	warning, err := app.ConnectKey("sk-test")
+	if err != nil {
+		t.Fatalf("ConnectKey: %v", err)
+	}
+	if !strings.Contains(warning, "another Reasonix window") {
+		t.Fatalf("ConnectKey warning = %q, want user-facing lease warning", warning)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed connect-key rebuild")
+	}
+	if tab.StartupErr != "" {
+		t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
+	}
+	if !config.CredentialStored(onboardingKeyEnv) {
+		t.Fatal("onboarding key should be persisted even when hot rebuild is deferred")
+	}
+}
+
+func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("set desktop language: %v", err)
+	}
+	if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil {
+		t.Fatalf("set desktop layout style: %v", err)
+	}
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
+		t.Fatalf("migrate desktop preferences: %v", err)
+	}
+
+	got := config.LoadForEdit(config.UserConfigPath())
+	if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
+		t.Fatalf("desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())
+	}
+}
+
+func TestSetEffortRebuildsController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetEffort("max"); err != nil {
+		t.Fatalf("SetEffort(max): %v", err)
+	}
+	if c := app.activeCtrl(); c == nil {
+		t.Fatal("SetEffort should leave a rebuilt controller")
+	}
+	if c := app.activeCtrl(); c == old {
+		t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
+	}
+	if got := app.Effort().Current; got != "max" {
+		t.Fatalf("Effort current = %q, want max", got)
+	}
+}
+
+func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "deepseek",
+		Kind:      "openai",
+		BaseURL:   "https://api.deepseek.com",
+		Model:     "glm-5",
+		APIKeyEnv: "DEEPSEEK_API_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetEffort("max"); err != nil {
+		t.Fatalf("SetEffort(max): %v", err)
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if tab.model != "deepseek/deepseek-v4-flash" {
+		t.Fatalf("tab model = %q, want migrated official ref", tab.model)
+	}
+}
+
+func TestSetTokenModeRebuildsController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetTokenMode("economy"); err != nil {
+		t.Fatalf("SetTokenMode(economy): %v", err)
+	}
+	if c := app.activeCtrl(); c == nil {
+		t.Fatal("SetTokenMode should leave a rebuilt controller")
+	}
+	if c := app.activeCtrl(); c == old {
+		t.Fatal("SetTokenMode should rebuild the active controller so the provider sees the new tool profile")
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if got := currentTabTokenMode(tab); got != "economy" {
+		t.Fatalf("token mode = %q, want economy", got)
+	}
+	if got := app.Meta().TokenMode; got != "economy" {
+		t.Fatalf("Meta token mode = %q, want economy", got)
+	}
+	saved := loadTabsFile()
+	if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" {
+		t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs)
+	}
+}
+
+func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
+		t.Fatalf("SetTokenMode(delivery): %v", err)
+	}
+	if c := app.activeCtrl(); c == nil || c == old {
+		t.Fatal("delivery profile should rebuild the active controller")
+	}
+	tab := app.activeTab()
+	if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
+		t.Fatalf("token mode = %q, want delivery", got)
+	}
+	if got := app.Meta().TokenMode; got != boot.TokenModeDelivery {
+		t.Fatalf("Meta token mode = %q, want delivery", got)
+	}
+	saved := loadTabsFile()
+	if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != boot.TokenModeDelivery {
+		t.Fatalf("saved tabs = %+v, want delivery profile", saved.Tabs)
+	}
+}
+
+func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	session := agent.NewSession("old system prompt")
+	session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
+	path := filepath.Join(dir, "leased-token-mode-switch.jsonl")
+	oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := tab.ensureSessionLease(path); err != nil {
+		t.Fatalf("ensureSessionLease: %v", err)
+	}
+	if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
+		t.Fatalf("SetTokenModeForTab: %v", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("tab controller was not rebuilt")
+	}
+	if got := currentTabTokenMode(tab); got != "economy" {
+		t.Fatalf("token mode = %q, want economy", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
+		t.Fatalf("carried history = %+v, want original user message", history)
+	}
+}
+
+func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "externally-leased-token-mode-switch.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(path)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	session := agent.NewSession("old system prompt")
+	session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err = app.SetTokenModeForTab(tab.ID, "economy")
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("SetTokenModeForTab err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if strings.Contains(err.Error(), path) || strings.Contains(err.Error(), "held by") {
+		t.Fatalf("SetTokenModeForTab surfaced raw lease details: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed switch")
+	}
+	if got := currentTabTokenMode(tab); got != "full" {
+		t.Fatalf("token mode = %q, want full", got)
+	}
+}
+
+func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "deepseek",
+		Kind:      "openai",
+		BaseURL:   "https://api.deepseek.com",
+		Model:     "glm-5",
+		APIKeyEnv: "DEEPSEEK_API_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetTokenMode("economy"); err != nil {
+		t.Fatalf("SetTokenMode(economy): %v", err)
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if tab.model != "deepseek/deepseek-v4-flash" {
+		t.Fatalf("tab model = %q, want migrated official ref", tab.model)
+	}
+	if got := currentTabTokenMode(tab); got != "economy" {
+		t.Fatalf("token mode = %q, want economy", got)
+	}
+}
+
+func TestMetaForTabReportsImageInputCapability(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "custom/text-only"
+	cfg.Desktop.ProviderAccess = []string{"custom"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:         "custom",
+		Kind:         "openai",
+		BaseURL:      "https://example.invalid/v1",
+		APIKeyEnv:    "CUSTOM_KEY",
+		Models:       []string{"text-only", "vision-pro"},
+		VisionModels: []string{"vision-pro"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.setTestCtrl(control.New(control.Options{Label: "custom/text-only"}), "custom/text-only")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if got := app.Meta().ImageInputEnabled; got {
+		t.Fatal("text-only meta should disable image input")
+	}
+	if err := app.SetModel("custom/vision-pro"); err != nil {
+		t.Fatalf("SetModel(custom/vision-pro): %v", err)
+	}
+	if got := app.Meta().ImageInputEnabled; !got {
+		t.Fatal("vision model meta should enable image input")
+	}
+}
+
+func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "custom/vision-pro"
+	cfg.Desktop.ProviderAccess = []string{"custom"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:         "custom",
+		Kind:         "openai",
+		BaseURL:      "https://example.invalid/v1",
+		APIKeyEnv:    "CUSTOM_KEY",
+		Models:       []string{"text-only", "vision-pro"},
+		VisionModels: []string{"vision-pro"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.setTestCtrl(control.New(control.Options{Label: "deleted/model"}), "deleted/model")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if got := app.Meta().ImageInputEnabled; got {
+		t.Fatal("unknown model ref should not inherit image input from the default fallback model")
+	}
+}
+
+func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	t.Setenv("MIMO_API_KEY", "")
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "missing-token-mode-model")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	err := app.SetTokenMode("economy")
+	if err == nil {
+		t.Fatal("SetTokenMode(economy) with an unknown model should fail")
+	}
+	if c := app.activeCtrl(); c != old {
+		t.Fatalf("SetTokenMode failure replaced controller: got %p want %p", c, old)
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if got := currentTabTokenMode(tab); got != "full" {
+		t.Fatalf("token mode after failed rebuild = %q, want full", got)
+	}
+	if got := app.Meta().TokenMode; got != "full" {
+		t.Fatalf("Meta token mode after failed rebuild = %q, want full", got)
+	}
+}
+
+func TestSetEffortRejectsRunningTurn(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
+	app.activeCtrl().Submit("work")
+	<-runner.started
+
+	err := app.SetEffort("max")
+	if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
+		t.Fatalf("SetEffort while running error = %v, want finish/cancel guard", err)
+	}
+
+	close(runner.release)
+	waitNotRunning(t, app.activeCtrl())
+}
+
+func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
+	app.activeCtrl().Submit("work")
+	<-runner.started
+
+	err := app.SetTokenMode("economy")
+	if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
+		t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err)
+	}
+
+	close(runner.release)
+	waitNotRunning(t, app.activeCtrl())
+}
+
+func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "jobs.jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	defer ctrl.Close()
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+
+	release := make(chan struct{})
+	jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		case <-release:
+			return "", nil
+		}
+	})
+	defer close(release)
+
+	err := app.SetTokenMode("economy")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
+	}
+}
+
+func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "settings-job.jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	defer ctrl.Close()
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash")
+
+	jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) {
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+
+	err := app.SetSandbox("enforce", true, "", nil, "")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err)
+	}
+}
+
+func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "clear-running.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
+	app := NewApp()
+	app.projectTreeChangedHook = func() {}
+	app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
+	app.tabs["test"].TopicID = "topic_clear"
+	app.tabs["test"].TopicTitle = "Clear topic"
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	oldCtrl.Submit("work")
+	<-runner.started
+	if err := app.ClearSession(); err != nil {
+		t.Fatalf("ClearSession: %v", err)
+	}
+	waitNotRunning(t, oldCtrl)
+	tab := app.activeTab()
+	if tab == nil || tab.Ctrl == nil {
+		t.Fatalf("active tab/controller missing after clear")
+	}
+	if tab.Ctrl == oldCtrl {
+		t.Fatalf("clear should replace the active controller after cancelling old work")
+	}
+	if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" {
+		t.Fatalf("clear changed topic identity: %+v", tab)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err)
+	}
+	if got := tab.currentSessionPath(); got == "" || got == path {
+		t.Fatalf("new session path = %q, want fresh path", got)
+	}
+}
+
+func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "clear-running-job.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	jm := jobs.NewManager(event.Discard)
+	oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	app := NewApp()
+	app.projectTreeChangedHook = func() {}
+	app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	started := make(chan struct{})
+	jm.StartForSession(agent.BranchID(path), "bash", "clear artifact", func(ctx context.Context, _ io.Writer) (string, error) {
+		close(started)
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+	<-started
+	jobsDir := jobs.ArtifactDir(path)
+	if _, err := os.Stat(jobsDir); err != nil {
+		t.Fatalf("job sidecar should exist before clear: %v", err)
+	}
+
+	if err := app.ClearSession(); err != nil {
+		t.Fatalf("ClearSession: %v", err)
+	}
+	if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
+		t.Fatalf("old job sidecar should be removed after clear, stat err = %v", err)
+	}
+}
+
+func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	dir := robustTempDir(t)
+	if err := os.MkdirAll(filepath.Join(dir, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "frontend", "wailsjs", "runtime", "runtime.js"), []byte("x"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "frontend", "Thumbs.db"), []byte("noise"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "frontend", ".DS_Store"), []byte("noise"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
+		if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		if err := os.WriteFile(filepath.Join(dir, noise, "runtime.js"), []byte("noise"), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "desktop", "frontend", "wailsjs"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "desktop", "frontend", "wailsjs", "runtime.js"), []byte("generated"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "product", "bin"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "product", "bin", "runtime.js"), []byte("real"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chdir(dir); err != nil {
+		t.Fatal(err)
+	}
+
+	app := &App{}
+	listed := app.ListDir("")
+	for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
+		if hasDirEntry(listed, hidden) {
+			t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
+		}
+	}
+	desktopFrontend := app.ListDir("desktop/frontend")
+	if hasDirEntry(desktopFrontend, "wailsjs") {
+		t.Fatalf("ListDir should hide generated Wails bindings, got %+v", desktopFrontend)
+	}
+	frontendEntries := app.ListDir("frontend")
+	for _, hidden := range []string{".DS_Store", "Thumbs.db"} {
+		if hasDirEntry(frontendEntries, hidden) {
+			t.Fatalf("ListDir should hide local noise file %q, got %+v", hidden, frontendEntries)
+		}
+	}
+
+	got := app.SearchFileRefs("runtime.js")
+	if !hasDirEntry(got, "frontend/wailsjs/runtime/runtime.js") {
+		t.Fatalf("SearchFileRefs(runtime.js) should find nested workspace file, got %+v", got)
+	}
+	if !hasDirEntry(got, "product/bin/runtime.js") {
+		t.Fatalf("SearchFileRefs should keep non-root bin directories searchable, got %+v", got)
+	}
+	if hasDirEntry(got, "node_modules/pkg/runtime.js") {
+		t.Fatalf("SearchFileRefs should skip node_modules noise, got %+v", got)
+	}
+	for _, hidden := range []string{
+		".codex/runtime.js",
+		".npm/runtime.js",
+		".pnpm-store/runtime.js",
+		"bin/runtime.js",
+		"desktop/frontend/wailsjs/runtime.js",
+		"dist/runtime.js",
+		"stage/runtime.js",
+		"tmp/runtime.js",
+	} {
+		if hasDirEntry(got, hidden) {
+			t.Fatalf("SearchFileRefs should skip local noise %q, got %+v", hidden, got)
+		}
+	}
+	if noise := app.SearchFileRefs("Thumbs"); hasDirEntry(noise, "frontend/Thumbs.db") {
+		t.Fatalf("SearchFileRefs should skip Thumbs.db noise, got %+v", noise)
+	}
+	if noise := app.SearchFileRefs(".DS"); hasDirEntry(noise, "frontend/.DS_Store") {
+		t.Fatalf("SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v", noise)
+	}
+}
+
+func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := robustTempDir(t)
+	projectRoot := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
+	if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.activeTabID = tab.ID
+
+	listed := app.ListDir("")
+	if !hasDirEntry(listed, "frontend") {
+		t.Fatalf("ListDir should list active project root, got %+v", listed)
+	}
+	if hasDirEntry(listed, "launch-only.txt") {
+		t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
+	}
+
+	found := app.SearchFileRefs("runtime.js")
+	if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
+		t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
+	}
+	preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
+	if preview.Err != "" || preview.Body != "right workspace" {
+		t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
+	}
+}
+
+func TestFileRefsForTabIgnoreActiveParentWorkspace(t *testing.T) {
+	parentRoot := robustTempDir(t)
+	childRoot := filepath.Join(parentRoot, "child")
+	if err := os.MkdirAll(childRoot, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(parentRoot, "parent-only.txt"), []byte("parent"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(parentRoot, "shared.txt"), []byte("parent shared"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(childRoot, "child-only.txt"), []byte("child"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child shared"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"parent": {ID: "parent", Scope: "project", WorkspaceRoot: parentRoot},
+			"child":  {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
+		},
+		activeTabID: "parent",
+	}
+
+	listed := app.ListDirForTab("child", "")
+	if !hasDirEntry(listed, "child-only.txt") || hasDirEntry(listed, "parent-only.txt") {
+		t.Fatalf("ListDirForTab(child) = %+v, want only child workspace entries", listed)
+	}
+	found := app.SearchFileRefsForTab("child", "child-only")
+	if !hasDirEntry(found, "child-only.txt") {
+		t.Fatalf("SearchFileRefsForTab(child) = %+v, want child-only.txt", found)
+	}
+	preview := app.ReadFileForTab("child", "shared.txt")
+	if preview.Err != "" || preview.Body != "child shared" {
+		t.Fatalf("ReadFileForTab(child) = %+v, want child workspace file", preview)
+	}
+	path, ok, err := app.workspaceOrExternalPathForTab("child", "shared.txt")
+	if err != nil || !ok || path != filepath.Join(childRoot, "shared.txt") {
+		t.Fatalf("workspaceOrExternalPathForTab(child) = (%q, %v, %v)", path, ok, err)
+	}
+
+	legacy := app.ReadFile("shared.txt")
+	if legacy.Err != "" || legacy.Body != "parent shared" {
+		t.Fatalf("ReadFile legacy active-tab behavior = %+v, want parent workspace file", legacy)
+	}
+}
+
+func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) {
+	workspace := robustTempDir(t)
+	external := filepath.Join(robustTempDir(t), "Folder With Spaces")
+	if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	expectedExternal := external
+	if resolved, err := filepath.EvalSymlinks(external); err == nil {
+		expectedExternal = resolved
+	}
+	expectedDisplayPath := filepath.ToSlash(expectedExternal)
+
+	ctrl := &control.Controller{}
+	token, _, err := ctrl.RegisterExternalFolderRef(external)
+	if err != nil {
+		t.Fatalf("RegisterExternalFolderRef: %v", err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
+			"other":   {ID: "other", WorkspaceRoot: robustTempDir(t)},
+		},
+		activeTabID: "other",
+	}
+
+	listed := app.ListDirForTab("project", token+"/src/")
+	if len(listed) != 1 ||
+		listed[0].Name != "outside.txt" ||
+		listed[0].Path != token+"/src/outside.txt" ||
+		listed[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
+		t.Fatalf("ListDir external src = %+v, want outside token/display path", listed)
+	}
+
+	found := app.SearchFileRefsForTab("project", "outside")
+	var externalHit *DirEntry
+	for i := range found {
+		if found[i].Path == token+"/src/outside.txt" {
+			externalHit = &found[i]
+			break
+		}
+	}
+	if externalHit == nil || externalHit.DisplayName != "Folder With Spaces/src/outside.txt" || externalHit.DisplayPath != expectedDisplayPath+"/src/outside.txt" {
+		t.Fatalf("SearchFileRefs external hit = %+v, all results %+v", externalHit, found)
+	}
+
+	preview := app.ReadFileForTab("project", token+"/src/outside.txt")
+	if preview.Err != "" || preview.Body != "outside" {
+		t.Fatalf("ReadFile external token preview = %+v, want outside file body", preview)
+	}
+}
+
+func TestDeleteSessionCancelsActiveRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "active.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+
+	app := NewApp()
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
+	keepPath := filepath.Join(dir, "keep.jsonl")
+	if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write keep session: %v", err)
+	}
+	keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
+	defer keepCtrl.Close()
+	app.setTestCtrl(activeCtrl, "")
+	app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
+	app.tabOrder = []string{"test", "keep"}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession(active basename): %v", err)
+	}
+	if _, ok := app.tabs["test"]; ok {
+		t.Fatalf("deleted active session runtime should be removed")
+	}
+	if got := app.activeTabID; got != "keep" {
+		t.Fatalf("active tab after delete = %q, want keep", got)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("active session should be moved out of active history, stat err = %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("active session should be moved to trash: %v", err)
+	}
+}
+
+func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	globalRoot := globalTabWorkspaceRoot()
+	dir := desktopSessionDir(globalRoot)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "pre-ready-blank.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write blank session: %v", err)
+	}
+	cancelled := false
+	blank := &WorkspaceTab{
+		ID:            "blank",
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		SessionPath:   path,
+		buildCancel:   func() { cancelled = true },
+		disabledMCP:   map[string]ServerView{},
+	}
+	keep := &WorkspaceTab{
+		ID:            "keep",
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"blank": blank, "keep": keep},
+		tabOrder:    []string{"blank", "keep"},
+		activeTabID: "blank",
+	}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession(pre-ready blank): %v", err)
+	}
+	if !cancelled {
+		t.Fatal("pre-ready blank build was not cancelled")
+	}
+	if !blank.removed {
+		t.Fatal("pre-ready blank tab was not marked removed")
+	}
+	if _, ok := app.tabs["blank"]; ok {
+		t.Fatal("pre-ready blank tab should be removed")
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("blank session should be moved out of active history, stat err = %v", err)
+	}
+}
+
+func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_delete_last"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Delete last"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := writeTopicSession(t, dir, "delete-last.jsonl", topicID, "Delete last", projectRoot)
+	ctrl := controllerWithContent(t, path)
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"only": {
+				ID:            "only",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       topicID,
+				TopicTitle:    "Delete last",
+				Ctrl:          ctrl,
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+		},
+		tabOrder:    []string{"only"},
+		activeTabID: "only",
+	}
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession(last topic session): %v", err)
+	}
+
+	if _, ok := app.tabs["only"]; ok {
+		t.Fatalf("deleted topic session tab should be removed")
+	}
+	for id, tab := range app.tabs {
+		if tab.TopicID == topicID {
+			t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
+		}
+		if strings.TrimSpace(tab.TopicID) != "" {
+			t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
+		}
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("deleted session should be moved to trash: %v", err)
+	}
+}
+
+func TestDeleteSessionFallbackKeepsTopicWithRemainingHistory(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_delete_keep_history"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := writeTopicSession(t, dir, "delete-one.jsonl", topicID, "Keep history", projectRoot)
+	remainingPath := writeTopicSessionWithPrompt(t, dir, "remaining.jsonl", topicID, "Keep history", projectRoot, "remaining turn", time.Now().Add(-time.Minute))
+	ctrl := controllerWithContent(t, path)
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"only": {
+				ID:            "only",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       topicID,
+				TopicTitle:    "Keep history",
+				Ctrl:          ctrl,
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+		},
+		tabOrder:    []string{"only"},
+		activeTabID: "only",
+	}
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession(topic with remaining history): %v", err)
+	}
+
+	found := false
+	for _, tab := range app.tabs {
+		if tab.TopicID == topicID {
+			found = true
+			if got := filepath.Clean(tab.currentSessionPath()); got != filepath.Clean(remainingPath) {
+				t.Fatalf("fallback session path = %q, want remaining history %q", got, remainingPath)
+			}
+		}
+	}
+	if !found {
+		t.Fatalf("fallback should keep topic %q when another session remains", topicID)
+	}
+}
+
+func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "stuck-delete.jsonl")
+	keepPath := filepath.Join(dir, "keep.jsonl")
+	for _, p := range []string{path, keepPath} {
+		if err := os.WriteFile(p, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write session %s: %v", p, err)
+		}
+	}
+
+	grace := 500 * time.Millisecond
+	slack := 300 * time.Millisecond
+	jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
+	releaseJob := startNonCooperativeSessionJob(t, jm, path)
+	defer func() {
+		releaseJob()
+		ctrl.Close()
+		keepCtrl.Close()
+	}()
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
+	app.tabOrder = []string{"test", "keep"}
+
+	start := time.Now()
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession(stuck job): %v", err)
+	}
+	elapsed := time.Since(start)
+	if elapsed > grace+slack {
+		t.Fatalf("DeleteSession took %s, want one teardown grace plus scheduling slack", elapsed)
+	}
+	if !agent.IsCleanupPending(path) {
+		t.Fatalf("stuck delete should mark cleanup pending")
+	}
+	if _, err := os.Stat(path); err != nil {
+		t.Fatalf("stuck session file should remain until delayed cleanup: %v", err)
+	}
+}
+
+func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "active-conflict.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {
+		t.Fatalf("create trash conflict: %v", err)
+	}
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	defer ctrl.Close()
+	ctrl.Submit("work")
+	<-runner.started
+
+	err := app.DeleteSession(filepath.Base(path))
+	if err != nil {
+		t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err)
+	}
+	if _, ok := app.tabs["test"]; ok {
+		t.Fatalf("deleted session runtime should be removed from tabs")
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("session file should be moved out of active history, stat err = %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("session should be moved to trash: %v", err)
+	}
+
+	close(runner.release)
+	waitNotRunning(t, ctrl)
+}
+
+func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "stale-live.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write live stub: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
+		t.Fatalf("create trash dir: %v", err)
+	}
+	if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write trash session: %v", err)
+	}
+
+	activePath := filepath.Join(dir, "active.jsonl")
+	if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write active session: %v", err)
+	}
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
+	defer activeCtrl.Close()
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
+		activeTabID: "active",
+		tabOrder:    []string{"active"},
+	}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession should remove stale live stub: %v", err)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("live stub should be removed, stat err = %v", err)
+	}
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("existing trash should remain authoritative: %v", err)
+	}
+}
+
+func TestDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "duplicate-recovery.jsonl")
+	content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
+	if err := os.WriteFile(path, content, 0o644); err != nil {
+		t.Fatalf("write live session: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
+		t.Fatalf("create trash dir: %v", err)
+	}
+	if err := os.WriteFile(trashPath, content, 0o644); err != nil {
+		t.Fatalf("write trash session: %v", err)
+	}
+
+	activePath := filepath.Join(dir, "active.jsonl")
+	if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write active session: %v", err)
+	}
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
+	defer activeCtrl.Close()
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
+		activeTabID: "active",
+		tabOrder:    []string{"active"},
+	}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession should remove duplicate live session: %v", err)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("duplicate live session should be removed, stat err = %v", err)
+	}
+	if got, err := os.ReadFile(trashPath); err != nil || string(got) != string(content) {
+		t.Fatalf("existing trash should remain authoritative, got %q err=%v", string(got), err)
+	}
+}
+
+func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "restore-open.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write trash source: %v", err)
+	}
+	if err := deleteSessionFile(dir, path); err != nil {
+		t.Fatalf("trash source: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write live stub: %v", err)
+	}
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open"})
+	defer ctrl.Close()
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"open": {ID: "open", Scope: "global", Ctrl: ctrl, Ready: true}},
+		tabOrder:    []string{"open"},
+		activeTabID: "open",
+	}
+
+	err := app.RestoreSession(trashPath)
+	if err == nil || !strings.Contains(err.Error(), "session is open") {
+		t.Fatalf("RestoreSession error = %v, want open-session rejection", err)
+	}
+	if info, statErr := os.Stat(path); statErr != nil || info.Size() != 0 {
+		t.Fatalf("open live stub should remain empty, info=%v err=%v", info, statErr)
+	}
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("trash session should remain after rejected restore: %v", err)
+	}
+}
+
+func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "real-live.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"new work"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write live session: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
+		t.Fatalf("create trash dir: %v", err)
+	}
+	if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write trash session: %v", err)
+	}
+
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "active"})
+	defer activeCtrl.Close()
+	app := NewApp()
+	app.setTestCtrl(activeCtrl, "")
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("live session should be moved out of active history, stat err = %v", err)
+	}
+	if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
+		t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
+	}
+	trashed, err := listTrashedSessionFiles(dir)
+	if err != nil {
+		t.Fatalf("list trash: %v", err)
+	}
+	var renamedPath string
+	for _, candidate := range trashed {
+		if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
+			renamedPath = candidate
+			break
+		}
+	}
+	if renamedPath == "" {
+		t.Fatalf("renamed trash copy not found in %#v", trashed)
+	}
+	if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
+		t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
+	}
+	if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
+		t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
+	}
+}
+
+func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	activePath := filepath.Join(dir, "active.jsonl")
+	inactivePath := filepath.Join(dir, "inactive.jsonl")
+	otherPath := filepath.Join(dir, "other.jsonl")
+	for _, path := range []string{activePath, inactivePath, otherPath} {
+		if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write session %s: %v", path, err)
+		}
+	}
+
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
+	inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
+	defer activeCtrl.Close()
+	defer inactiveCtrl.Close()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"active":   {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
+			"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
+		},
+		tabOrder:    []string{"active", "inactive"},
+		activeTabID: "active",
+	}
+
+	if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {
+		t.Fatalf("DeleteSession(inactive open basename): %v", err)
+	}
+	if _, ok := app.tabs["inactive"]; ok {
+		t.Fatalf("deleted inactive session runtime should be removed")
+	}
+	if _, err := os.Stat(inactivePath); !os.IsNotExist(err) {
+		t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("inactive open session should be moved to trash: %v", err)
+	}
+
+	sessions := app.ListSessions()
+	current := map[string]bool{}
+	open := map[string]bool{}
+	for _, s := range sessions {
+		current[filepath.Base(s.Path)] = s.Current
+		open[filepath.Base(s.Path)] = s.Open
+	}
+	if !current[filepath.Base(activePath)] {
+		t.Fatalf("ListSessions should mark active session current, got %#v", current)
+	}
+	if current[filepath.Base(otherPath)] {
+		t.Fatalf("ListSessions marked unopened session current, got %#v", current)
+	}
+	if !open[filepath.Base(activePath)] {
+		t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
+	}
+	if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {
+		t.Fatalf("ListSessions marked unopened session open, got %#v", open)
+	}
+}
+
+func TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_stuck_trash"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Stuck trash"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	sessionPath := writeTopicSession(t, dir, "stuck-topic.jsonl", topicID, "Stuck trash", projectRoot)
+
+	grace := 500 * time.Millisecond
+	slack := 300 * time.Millisecond
+	jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", Jobs: jm, WorkspaceRoot: projectRoot})
+	releaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)
+	defer func() {
+		releaseJob()
+		ctrl.Close()
+	}()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"stuck": {
+				ID:            "stuck",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       topicID,
+				TopicTitle:    "Stuck trash",
+				Ctrl:          ctrl,
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+			"keep": {
+				ID:            "keep",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       "topic_keep",
+				TopicTitle:    "Keep",
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+		},
+		tabOrder:    []string{"stuck", "keep"},
+		activeTabID: "stuck",
+	}
+
+	start := time.Now()
+	if err := app.TrashTopic(topicID); err != nil {
+		t.Fatalf("TrashTopic(stuck job): %v", err)
+	}
+	elapsed := time.Since(start)
+	if elapsed > grace+slack {
+		t.Fatalf("TrashTopic took %s, want one teardown grace plus scheduling slack", elapsed)
+	}
+	if !agent.IsCleanupPending(sessionPath) {
+		t.Fatalf("stuck topic trash should mark cleanup pending")
+	}
+	if _, err := os.Stat(sessionPath); err != nil {
+		t.Fatalf("stuck topic session should remain until delayed trash: %v", err)
+	}
+}
+
+func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "trash-me.jsonl")
+	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	if err := deleteSessionFile(dir, sessionPath); err != nil {
+		t.Fatalf("deleteSessionFile: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
+
+	jm := jobs.NewManager(event.Discard)
+	defer jm.Close()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "active", Jobs: jm})
+	defer ctrl.Close()
+	destroy := ctrl.BeginDestroySession(sessionPath)
+	defer destroy.Finish()
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	if err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), "cleanup is still in progress") {
+		t.Fatalf("RestoreSession while destroying error = %v, want cleanup-in-progress", err)
+	}
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("trashed session should remain after rejected restore: %v", err)
+	}
+
+	destroy.Finish()
+	if err := app.RestoreSession(trashPath); err != nil {
+		t.Fatalf("RestoreSession after finish: %v", err)
+	}
+	if _, err := os.Stat(sessionPath); err != nil {
+		t.Fatalf("session should be restored: %v", err)
+	}
+}
+
+func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dirA := filepath.Join(t.TempDir(), "workspace-a-sessions")
+	dirB := filepath.Join(t.TempDir(), "workspace-b-sessions")
+	if err := os.MkdirAll(dirA, 0o755); err != nil {
+		t.Fatalf("mkdir dirA: %v", err)
+	}
+	if err := os.MkdirAll(dirB, 0o755); err != nil {
+		t.Fatalf("mkdir dirB: %v", err)
+	}
+	pathA := filepath.Join(dirA, "a.jsonl")
+	pathB := filepath.Join(dirB, "b.jsonl")
+	if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write pathA: %v", err)
+	}
+	if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write pathB: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	sessions := app.ListSessions()
+	if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" {
+		t.Fatalf("ListSessions should read the active controller session dir only, got %+v", sessions)
+	}
+	if err := app.RenameSession(pathA, "A title"); err != nil {
+		t.Fatalf("RenameSession in active session dir: %v", err)
+	}
+	meta, ok, err := agent.LoadBranchMeta(pathA)
+	if err != nil || !ok {
+		t.Fatalf("LoadBranchMeta after RenameSession ok=%v err=%v", ok, err)
+	}
+	if meta.CustomTitle != "A title" {
+		t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle)
+	}
+	sessions = app.ListSessions()
+	if len(sessions) != 1 || sessions[0].Title != "A title" {
+		t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions)
+	}
+	if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" {
+		t.Fatalf("title should be written beside the active session, got %+v", titles)
+	}
+	if titles := loadSessionTitles(dirB); len(titles) != 0 {
+		t.Fatalf("inactive workspace title sidecar should remain untouched, got %+v", titles)
+	}
+}
+
+func TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	cfg := config.Default()
+	cfg.Bot.Connections = []config.BotConnectionConfig{{
+		ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
+		SessionMappings: []config.BotConnectionSessionMapping{{
+			RemoteID: "wx-chat-1", SessionID: "path:" + path, SessionSource: "auto",
+		}},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	sessions := app.ListSessions()
+	if len(sessions) != 1 {
+		t.Fatalf("ListSessions len = %d, want 1: %+v", len(sessions), sessions)
+	}
+	got := sessions[0]
+	if got.Kind != "channel" || got.Channel != "weixin" || got.ChannelLabel != "微信" || got.RemoteID != "wx-chat-1" || got.SessionSource != "auto" {
+		t.Fatalf("channel session meta = %+v", got)
+	}
+}
+
+func TestDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	other := filepath.Join(dir, "other-channel.jsonl")
+	cfg := config.Default()
+	cfg.Bot.Connections = []config.BotConnectionConfig{{
+		ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
+		SessionMappings: []config.BotConnectionSessionMapping{
+			{RemoteID: "remove-auto", SessionID: "path:" + path, SessionSource: "auto"},
+			{RemoteID: "keep-explicit", SessionID: "path:" + path},
+			{RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
+		},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer app.activeCtrl().Close()
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession: %v", err)
+	}
+
+	got := config.LoadForEdit(config.UserConfigPath())
+	mappings := got.Bot.Connections[0].SessionMappings
+	if len(mappings) != 2 {
+		t.Fatalf("session mappings = %+v, want explicit and other auto mappings preserved", mappings)
+	}
+	for _, mapping := range mappings {
+		if mapping.RemoteID == "remove-auto" {
+			t.Fatalf("deleted session auto mapping was preserved: %+v", mappings)
+		}
+	}
+}
+
+func TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer app.activeCtrl().Close()
+
+	if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
+		t.Fatalf("OpenChannelSessionForTab: %v", err)
+	}
+	if meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {
+		t.Fatalf("channel tab should be read-only: %+v", meta)
+	}
+	before, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read before: %v", err)
+	}
+	app.SubmitToTab("test", "must not append")
+	app.RunShellForTab("test", "echo must-not-run")
+	after, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read after: %v", err)
+	}
+	if string(after) != string(before) {
+		t.Fatalf("read-only channel transcript changed:\nbefore=%s\nafter=%s", before, after)
+	}
+
+	f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
+	if err != nil {
+		t.Fatalf("open append: %v", err)
+	}
+	if _, err := f.WriteString(`{"role":"user","content":"external follow-up"}` + "\n"); err != nil {
+		f.Close()
+		t.Fatalf("append external message: %v", err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatalf("close append: %v", err)
+	}
+	app.snapshotAllTabs()
+	afterSnapshot, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read after snapshot: %v", err)
+	}
+	if !strings.Contains(string(afterSnapshot), "external follow-up") {
+		t.Fatalf("read-only channel snapshot overwrote external append:\n%s", afterSnapshot)
+	}
+}
+
+func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) {
+	tests := []struct {
+		name string
+		app  *App
+		call func(*App) error
+		want string
+	}{
+		{
+			name: "submit read-only",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", ReadOnly: true}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.SubmitToTab("test", "hello") },
+			want: "read-only",
+		},
+		{
+			name: "submit workspace unavailable",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", StartupErr: "boom"}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.SubmitToTab("test", "hello") },
+			want: "workspace failed to start: boom",
+		},
+		{
+			name: "run shell workspace unavailable",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.RunShellForTab("test", "echo hi") },
+			want: "workspace is still starting",
+		},
+		{
+			name: "steer workspace unavailable",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.SteerForTab("test", "please continue") },
+			want: "workspace is still starting",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.call(tt.app)
+			if err == nil {
+				t.Fatalf("expected error containing %q", tt.want)
+			}
+			if !strings.Contains(err.Error(), tt.want) {
+				t.Fatalf("error = %q, want to contain %q", err, tt.want)
+			}
+		})
+	}
+}
+
+func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer ctrl.Close()
+
+	if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
+		t.Fatalf("OpenChannelSessionForTab: %v", err)
+	}
+	app.mu.Lock()
+	app.tabs["survivor"] = &WorkspaceTab{ID: "survivor", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
+	app.tabOrder = []string{"test", "survivor"}
+	app.activeTabID = "test"
+	app.mu.Unlock()
+
+	f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
+	if err != nil {
+		t.Fatalf("open append: %v", err)
+	}
+	if _, err := f.WriteString(`{"role":"user","content":"external close follow-up"}` + "\n"); err != nil {
+		f.Close()
+		t.Fatalf("append external message: %v", err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatalf("close append: %v", err)
+	}
+
+	if err := app.CloseTab("test"); err != nil {
+		t.Fatalf("CloseTab: %v", err)
+	}
+	afterClose, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read after close: %v", err)
+	}
+	if !strings.Contains(string(afterClose), "external close follow-up") {
+		t.Fatalf("closing read-only channel tab overwrote external append:\n%s", afterClose)
+	}
+}
+
+func TestResumeSessionRejectsCleanupPending(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	activePath := filepath.Join(dir, "active.jsonl")
+	pendingPath := filepath.Join(dir, "pending.jsonl")
+	for _, path := range []string{activePath, pendingPath} {
+		if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write %s: %v", path, err)
+		}
+	}
+	if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer app.activeCtrl().Close()
+
+	if _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
+		t.Fatalf("ResumeSession cleanup-pending error = %v, want pending cleanup", err)
+	}
+	if got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {
+		t.Fatalf("active session path after rejected resume = %q, want %q", got, activePath)
+	}
+	if _, err := app.OpenChannelSessionForTab("test", pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
+		t.Fatalf("OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup", err)
+	}
+	if meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {
+		t.Fatalf("rejected channel open should not make tab read-only: %+v", meta)
+	}
+}
+
+func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {
+	dirA := t.TempDir()
+	dirB := t.TempDir()
+	activePath := filepath.Join(dirA, "active.jsonl")
+	outsidePath := filepath.Join(dirB, "outside.jsonl")
+	for _, path := range []string{activePath, outsidePath} {
+		if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write %s: %v", path, err)
+		}
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	if _, err := app.ResumeSession(outsidePath); err == nil {
+		t.Fatal("ResumeSession should reject a transcript outside the active session dir")
+	}
+	if _, err := app.PreviewSession(outsidePath); err == nil {
+		t.Fatal("PreviewSession should reject a transcript outside the active session dir")
+	}
+}
+
+func BenchmarkDesktopListSessionsScoped(b *testing.B) {
+	dirA := filepath.Join(b.TempDir(), "workspace-a-sessions")
+	dirB := filepath.Join(b.TempDir(), "workspace-b-sessions")
+	for _, dir := range []string{dirA, dirB} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			b.Fatalf("mkdir %s: %v", dir, err)
+		}
+		for i := 0; i < 120; i++ {
+			path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
+			body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
+			if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+				b.Fatalf("write session: %v", err)
+			}
+		}
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	b.ReportAllocs()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		sessions := app.ListSessions()
+		if len(sessions) != 120 {
+			b.Fatalf("ListSessions len = %d, want 120", len(sessions))
+		}
+	}
+}
+
+type appendingDesktopRunner struct {
+	session *agent.Session
+	started chan string
+}
+
+func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
+	r.started <- input
+	r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
+	r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
+	return nil
+}
+
+func TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	path := filepath.Join(dir, "memory-display.jsonl")
+	sess := agent.NewSession("sys")
+	exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+	runner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}
+	ctrl := control.New(control.Options{
+		Runner:      runner,
+		Executor:    exec,
+		Sink:        event.Discard,
+		SessionDir:  dir,
+		SessionPath: path,
+		Label:       "test",
+	})
+	defer ctrl.Close()
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "deepseek/test")
+	ctrl.QueueMemory(`Saved memory "reasonix-contributions": contribution count updated`)
+
+	const prompt = "不要,删了"
+	app.SubmitToTab("test", prompt)
+	composed := <-runner.started
+	waitNotRunning(t, ctrl)
+
+	if !strings.Contains(composed, "") || !strings.HasSuffix(composed, prompt) {
+		t.Fatalf("model input should include memory update followed by prompt, got %q", composed)
+	}
+	got := app.HistoryForTab("test")
+	if len(got) < 2 {
+		t.Fatalf("history length = %d, want user + assistant", len(got))
+	}
+	if got[0].Role != "system" || got[1].Role != "user" {
+		t.Fatalf("history roles = %+v, want system then user", got[:min(len(got), 2)])
+	}
+	if got[1].Content != prompt {
+		t.Fatalf("displayed user content = %q, want %q", got[1].Content, prompt)
+	}
+	if strings.Contains(got[1].Content, "") {
+		t.Fatalf("displayed user content leaked memory update: %q", got[1].Content)
+	}
+}
+
+func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	workspace := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(""), 0o644); err != nil {
+		t.Fatalf("write workspace config: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := agent.NewSessionPath(dir, "test")
+	sess := agent.NewSession("sys")
+	exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+	runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}
+	ctrl := control.New(control.Options{
+		Runner:        runner,
+		Executor:      exec,
+		Sink:          event.Discard,
+		SessionDir:    dir,
+		SessionPath:   path,
+		Label:         "test",
+		WorkspaceRoot: workspace,
+	})
+	app := NewApp()
+	app.setTestCtrl(ctrl, "deepseek/test")
+	app.tabs["test"].Scope = "project"
+	app.tabs["test"].WorkspaceRoot = workspace
+	app.tabs["test"].TopicID = "topic_source"
+	app.tabs["test"].TopicTitle = "Source topic"
+	defer ctrl.Close()
+
+	ctrl.Submit("first")
+	<-runner.started
+	waitNotRunning(t, ctrl)
+	ctrl.Submit("second")
+	<-runner.started
+	waitNotRunning(t, ctrl)
+	if got := len(ctrl.History()); got != 5 {
+		t.Fatalf("source history len before fork = %d, want 5", got)
+	}
+
+	meta, err := app.Fork(1)
+	if err != nil {
+		t.Fatalf("Fork: %v", err)
+	}
+	if !meta.Active || meta.ID == "" || meta.ID == "test" {
+		t.Fatalf("fork meta = %+v, want a new active tab", meta)
+	}
+	if got := app.activeTabID; got != meta.ID {
+		t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID)
+	}
+	if got := ctrl.SessionPath(); got != path {
+		t.Fatalf("source controller session path = %q, want %q", got, path)
+	}
+	if got := len(ctrl.History()); got != 5 {
+		t.Fatalf("source history len after fork = %d, want 5", got)
+	}
+	if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want {
+		t.Fatalf("fork topic title = %q, want %q", got, want)
+	}
+
+	var forkPath string
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatalf("read session dir: %v", err)
+	}
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		candidate := filepath.Join(dir, entry.Name())
+		if candidate == path {
+			continue
+		}
+		m, ok, err := agent.LoadBranchMeta(candidate)
+		if err != nil {
+			t.Fatalf("load fork meta: %v", err)
+		}
+		if ok && m.TopicID == meta.TopicID {
+			forkPath = candidate
+			if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {
+				t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path))
+			}
+			if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" {
+				t.Fatalf("fork topic meta = %+v", m)
+			}
+		}
+	}
+	if forkPath == "" {
+		t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir)
+	}
+}
+
+func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "playwright" {
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
+				t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
+	home := isolateDesktopUserDirs(t)
+	reasonixHome := filepath.Join(home, ".reasonix")
+	root := filepath.Join(reasonixHome, "plugins", "superpowers")
+	if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
+  "name": "superpowers",
+  "version": "6.1.0",
+  "description": "Planning workflows",
+  "skills": "./skills/"
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
+		Name:         "superpowers",
+		Root:         "plugins/superpowers",
+		Version:      "6.1.0",
+		Description:  "Planning workflows",
+		ManifestKind: "codex",
+		Enabled:      true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	plugins := app.Capabilities().Plugins
+	if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
+		t.Fatalf("Capabilities().Plugins = %+v", plugins)
+	}
+	if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/superpowers:plan" {
+		t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
+	}
+}
+
+func TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping background MCP boot integration test in short mode")
+	}
+
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
+[[plugins]]
+name = "h"
+type = "http"
+url = %q
+`, srv.URL)), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	sharedHost := plugin.NewHost()
+	defer sharedHost.Close()
+	ctrl, err := boot.Build(ctx, boot.Options{
+		WorkspaceRoot: dir,
+		SessionDir:    filepath.Join(dir, "sessions"),
+		SharedHost:    sharedHost,
+		Stderr:        io.Discard,
+	})
+	if err != nil {
+		t.Fatalf("boot.Build: %v", err)
+	}
+	defer ctrl.Close()
+
+	deadline := time.Now().Add(3 * time.Second)
+	for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
+		time.Sleep(25 * time.Millisecond)
+	}
+	if !sharedHost.HasClient("h") {
+		t.Fatalf("background MCP did not auto-connect; connecting=%v failures=%+v", sharedHost.ConnectingServers(), sharedHost.Failures())
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{
+		"test": {
+			ID:            "test",
+			Scope:         "global",
+			WorkspaceRoot: dir,
+			Ready:         true,
+			Ctrl:          ctrl,
+			SharedHostKey: dir,
+			disabledMCP:   map[string]ServerView{},
+		},
+	}
+	app.activeTabID = "test"
+
+	view := app.MCPServers()
+	if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].StartIntent != "automatic" || view[0].RuntimeState != "ready" || view[0].Tools != 1 {
+		t.Fatalf("MCPServers() = %+v, want h connected automatic ready with one tool", view)
+	}
+}
+
+func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {
+		t.Fatalf("MCPServers() = %+v, want Capabilities().Servers %+v", got, want)
+	}
+}
+
+func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "time"
+command = "custom-time"
+args = ["serve"]
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	found := false
+	for _, s := range view.Servers {
+		if s.Name != "time" {
+			continue
+		}
+		found = true
+		if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
+			t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
+		}
+	}
+	if !found {
+		t.Fatalf("configured time server missing from Capabilities: %+v", view.Servers)
+	}
+
+	if err := app.SetMCPServerEnabled("time", false); err != nil {
+		t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
+	}
+	view = app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "time" {
+			if s.Status != "disabled" || s.BuiltIn || s.Command != "custom-time" {
+				t.Fatalf("disabled configured time view = %+v, want disabled external config", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("time missing after disable: %+v", view.Servers)
+}
+
+func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
+[[plugins]]
+name = "h"
+type = "http"
+url = %q
+`, srv.URL)), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	sharedHost := plugin.NewHost()
+	defer sharedHost.Close()
+	tools, err := sharedHost.Add(ctx, plugin.Spec{Name: "h", Type: "http", URL: srv.URL})
+	if err != nil {
+		t.Fatalf("sharedHost.Add: %v", err)
+	}
+
+	activeRegistry := tool.NewRegistry()
+	siblingRegistry := tool.NewRegistry()
+	for _, mt := range tools {
+		activeRegistry.Add(mt)
+		siblingRegistry.Add(mt)
+	}
+	activeCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})
+	siblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{
+		"active": {
+			ID:            "active",
+			Scope:         "global",
+			WorkspaceRoot: dir,
+			Ready:         true,
+			Ctrl:          activeCtrl,
+			SharedHostKey: dir,
+			disabledMCP:   map[string]ServerView{},
+		},
+		"sibling": {
+			ID:            "sibling",
+			Scope:         "global",
+			WorkspaceRoot: dir,
+			Ready:         true,
+			Ctrl:          siblingCtrl,
+			SharedHostKey: dir,
+			disabledMCP:   map[string]ServerView{},
+		},
+	}
+	app.activeTabID = "active"
+
+	if err := app.SetMCPServerEnabled("h", false); err != nil {
+		t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
+	}
+	if _, found := activeRegistry.Get("mcp__h__greet"); found {
+		t.Fatal("active tab still has h tools after disabling the shared server")
+	}
+	if _, found := siblingRegistry.Get("mcp__h__greet"); !found {
+		t.Fatal("sibling tab lost h tools when active tab disabled the shared server")
+	}
+	if !sharedHost.HasClient("h") {
+		t.Fatal("shared host client was removed by a per-tab disable")
+	}
+	view := app.Capabilities()
+	if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "disabled" {
+		t.Fatalf("Capabilities after disable = %+v, want h disabled for the active tab", view.Servers)
+	}
+
+	if err := app.SetMCPServerEnabled("h", true); err != nil {
+		t.Fatalf("SetMCPServerEnabled(h,true): %v", err)
+	}
+	if _, found := activeRegistry.Get("mcp__h__greet"); !found {
+		t.Fatal("active tab did not re-register h tools from the existing shared client")
+	}
+	view = app.Capabilities()
+	if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "connected" {
+		t.Fatalf("Capabilities after re-enable = %+v, want h connected for the active tab", view.Servers)
+	}
+}
+
+func TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.setTestCtrl(newBackgroundJobController(t, "mcp-enabled-job"), "")
+
+	err := app.SetMCPServerEnabled("time", false)
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetMCPServerEnabled with background job error = %v, want active-work guard", err)
+	}
+	if tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {
+		t.Fatalf("disabled MCP state changed after rejected toggle: %+v", tab)
+	}
+}
+
+func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "time"
+command = "custom-time"
+args = ["serve"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.UpdateMCPServer("time", MCPServerInput{
+		Name:      "time",
+		Transport: "stdio",
+		Command:   "updated-time",
+		Args:      []string{"run"},
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer(time): %v", err)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	updated, ok := findPluginEntry(cfg.Plugins, "time")
+	if !ok || updated.Command != "updated-time" || !reflect.DeepEqual(updated.Args, []string{"run"}) {
+		t.Fatalf("updated time plugin = %+v, found=%v", updated, ok)
+	}
+
+	if err := app.RemoveMCPServer("time"); err != nil {
+		t.Fatalf("RemoveMCPServer(time): %v", err)
+	}
+	cfg, err = config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
+		t.Fatalf("time plugin still configured after remove: %+v", cfg.Plugins)
+	}
+}
+
+func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+	recordMCPFailure(app.activeCtrl(), config.PluginEntry{
+		Name:    "broken",
+		Command: "reasonix-missing-mcp-binary",
+	}, errors.New("connect: missing binary"))
+
+	view := app.Capabilities()
+	if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" {
+		t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers)
+	}
+
+	if err := app.RemoveMCPServer("broken"); err != nil {
+		t.Fatalf("RemoveMCPServer(broken): %v", err)
+	}
+	if mcpFailed(app.activeCtrl(), "broken") {
+		t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures())
+	}
+	view = app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers)
+		}
+	}
+}
+
+func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
+  "mcpServers": {
+    "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] },
+    "keep": { "command": "keep-mcp" }
+  }
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.RemoveMCPServer("codegraph"); err != nil {
+		t.Fatalf("RemoveMCPServer(.mcp.json codegraph): %v", err)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := findPluginEntry(cfg.Plugins, "codegraph"); ok {
+		t.Fatalf("codegraph still merged after remove: %+v", cfg.Plugins)
+	}
+	if _, ok := findPluginEntry(cfg.Plugins, "keep"); !ok {
+		t.Fatalf("unrelated .mcp.json server should be preserved: %+v", cfg.Plugins)
+	}
+}
+
+func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testing.T) {
+	home := isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	reasonixHome := filepath.Join(home, ".reasonix")
+	root := filepath.Join(reasonixHome, "plugins", "superpowers")
+	if err := os.MkdirAll(root, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{
+  "name": "superpowers",
+  "version": "1.0.0",
+  "mcpServers": {
+    "helper": { "type": "http", "url": %q }
+  }
+}`, srv.URL)), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
+		Name:         "superpowers",
+		Root:         "plugins/superpowers",
+		Version:      "1.0.0",
+		ManifestKind: "reasonix",
+		Enabled:      true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entry, ok := findPluginEntry(cfg.Plugins, "helper")
+	if !ok {
+		t.Fatalf("plugin-managed MCP missing from config: %+v", cfg.Plugins)
+	}
+	ctrl := control.New(control.Options{Host: plugin.NewHost()})
+	defer ctrl.Close()
+	if _, err := ctrl.ConnectMCPServer(entry); err != nil {
+		t.Fatalf("connect plugin-managed MCP: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	app.activeTab().WorkspaceRoot = dir
+	err = app.RemoveMCPServer("helper")
+	if err == nil || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
+		t.Fatalf("RemoveMCPServer(plugin-managed) error = %v", err)
+	}
+	if !mcpConnected(ctrl, "helper") {
+		t.Fatal("plugin-managed MCP was disconnected despite rejected removal")
+	}
+	for action, actionErr := range map[string]error{
+		"trust tool": app.TrustMCPServerTool("helper", "echo"),
+		"clear auth": app.ClearMCPServerAuthentication("helper"),
+		"update":     app.UpdateMCPServer("helper", MCPServerInput{Name: "helper", Transport: "http", URL: srv.URL}),
+	} {
+		if actionErr == nil || !strings.Contains(actionErr.Error(), "managed by plugin") {
+			t.Fatalf("%s plugin-managed MCP error = %v", action, actionErr)
+		}
+	}
+	if _, found := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "helper"); found {
+		t.Fatal("plugin-managed MCP mutation created a user-config shadow")
+	}
+	servers := app.MCPServers()
+	if len(servers) != 1 || servers[0].Name != "helper" || servers[0].ManagedByPlugin != "superpowers" {
+		t.Fatalf("MCPServers() = %+v, want helper managed by superpowers", servers)
+	}
+}
+
+func TestRemoveMCPServerRejectsRuntimeOnlyServerWithoutDisconnecting(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	ctrl := control.New(control.Options{Host: plugin.NewHost()})
+	defer ctrl.Close()
+	if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "runtime-only", Type: "http", URL: srv.URL}); err != nil {
+		t.Fatalf("connect runtime-only MCP: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	app.activeTab().WorkspaceRoot = dir
+	err := app.RemoveMCPServer("runtime-only")
+	if err == nil || !strings.Contains(err.Error(), "no removable MCP server") {
+		t.Fatalf("RemoveMCPServer(runtime-only) error = %v", err)
+	}
+	if !mcpConnected(ctrl, "runtime-only") {
+		t.Fatal("runtime-only MCP was disconnected despite failed persistence removal")
+	}
+}
+
+func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
+  "mcpServers": {
+    "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
+  }
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.UpdateMCPServer("codegraph", MCPServerInput{
+		Name:      "codegraph",
+		Transport: "stdio",
+		Command:   "reasonix-missing-mcp-binary",
+		Args:      []string{"serve", "--mcp"},
+		Env:       map[string]string{"CODEGRAPH_LOG": "debug"},
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer(.mcp.json codegraph): %v", err)
+	}
+
+	raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var doc struct {
+		MCPServers map[string]struct {
+			Command string            `json:"command"`
+			Args    []string          `json:"args"`
+			Env     map[string]string `json:"env"`
+		} `json:"mcpServers"`
+	}
+	if err := json.Unmarshal(raw, &doc); err != nil {
+		t.Fatal(err)
+	}
+	got := doc.MCPServers["codegraph"]
+	if got.Command != "reasonix-missing-mcp-binary" || !reflect.DeepEqual(got.Args, []string{"serve", "--mcp"}) || got.Env["CODEGRAPH_LOG"] != "debug" {
+		t.Fatalf(".mcp.json codegraph = %+v, want updated command/args/env", got)
+	}
+	if _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "codegraph"); ok {
+		t.Fatalf(".mcp.json update should not create a user config shadow entry")
+	}
+}
+
+func TestTrustMCPServerToolPersistsTrustedReadOnlyTools(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "github"
+command = "npx"
+args = ["-y", "@modelcontextprotocol/server-github"]
+trusted_read_only_tools = ["pull_request_read"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.TrustMCPServerTool("github", " issue_read "); err != nil {
+		t.Fatalf("TrustMCPServerTool(github, issue_read): %v", err)
+	}
+	if err := app.TrustMCPServerTool("github", "issue_read"); err != nil {
+		t.Fatalf("TrustMCPServerTool duplicate: %v", err)
+	}
+	if err := app.TrustMCPServerTools("github", []string{" search_issues ", "issue_read", ""}); err != nil {
+		t.Fatalf("TrustMCPServerTools(github): %v", err)
+	}
+	if err := app.UntrustMCPServerTool("github", " pull_request_read "); err != nil {
+		t.Fatalf("UntrustMCPServerTool(github, pull_request_read): %v", err)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	updated, ok := findPluginEntry(cfg.Plugins, "github")
+	if !ok {
+		t.Fatalf("github plugin missing: %+v", cfg.Plugins)
+	}
+	if !reflect.DeepEqual(updated.TrustedReadOnlyTools, []string{"issue_read", "search_issues"}) {
+		t.Fatalf("trusted read-only tools = %+v", updated.TrustedReadOnlyTools)
+	}
+	for _, s := range app.MCPServers() {
+		if s.Name == "github" {
+			if !reflect.DeepEqual(s.TrustedReadOnlyTools, []string{"issue_read", "search_issues"}) {
+				t.Fatalf("view trusted read-only tools = %+v", s.TrustedReadOnlyTools)
+			}
+			return
+		}
+	}
+	t.Fatalf("github MCP missing from view")
+}
+
+func TestTrustMCPServerToolPersistsProjectMCPJSONEntry(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
+  "mcpServers": {
+    "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
+  }
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.TrustMCPServerTool("codegraph", "codegraph_context"); err != nil {
+		t.Fatalf("TrustMCPServerTool(.mcp.json codegraph): %v", err)
+	}
+
+	raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var doc struct {
+		MCPServers map[string]struct {
+			TrustedReadOnlyTools []string `json:"trusted_read_only_tools"`
+		} `json:"mcpServers"`
+	}
+	if err := json.Unmarshal(raw, &doc); err != nil {
+		t.Fatal(err)
+	}
+	if !reflect.DeepEqual(doc.MCPServers["codegraph"].TrustedReadOnlyTools, []string{"codegraph_context"}) {
+		t.Fatalf(".mcp.json trusted_read_only_tools = %+v", doc.MCPServers["codegraph"].TrustedReadOnlyTools)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	updated, ok := findPluginEntry(cfg.Plugins, "codegraph")
+	if !ok || !reflect.DeepEqual(updated.TrustedReadOnlyTools, []string{"codegraph_context"}) {
+		t.Fatalf("merged codegraph trusted_read_only_tools = %+v, found=%v", updated.TrustedReadOnlyTools, ok)
+	}
+}
+
+func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	t.Setenv("STRIPE_TOKEN", "stripe-test-token")
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	tools, err := app.AddMCPServer(MCPServerInput{
+		Name:      "stripe",
+		Transport: "http",
+		URL:       srv.URL,
+		Headers: map[string]string{
+			"Authorization": "Bearer ${STRIPE_TOKEN}",
+			"X-Org":         "team",
+		},
+	})
+	if err != nil {
+		t.Fatalf("AddMCPServer(stripe): %v", err)
+	}
+	if tools != 1 {
+		t.Fatalf("tools = %d, want 1", tools)
+	}
+
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	p, ok := findPluginEntry(cfg.Plugins, "stripe")
+	if !ok {
+		t.Fatalf("stripe plugin missing from config: %+v", cfg.Plugins)
+	}
+	if p.Type != "http" || p.URL != srv.URL {
+		t.Fatalf("stripe plugin transport = %q url = %q", p.Type, p.URL)
+	}
+	if p.Headers["Authorization"] != "Bearer ${STRIPE_TOKEN}" || p.Headers["X-Org"] != "team" {
+		t.Fatalf("stripe headers = %+v", p.Headers)
+	}
+
+	view := app.MCPServers()
+	for _, s := range view {
+		if s.Name == "stripe" {
+			if !reflect.DeepEqual(s.HeaderKeys, []string{"Authorization", "X-Org"}) {
+				t.Fatalf("stripe header keys = %+v", s.HeaderKeys)
+			}
+			return
+		}
+	}
+	t.Fatalf("stripe MCP missing from view: %+v", view)
+}
+
+func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "dida"
+type = "http"
+url = "https://mcp.dida365.com"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "dida" {
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
+				t.Fatalf("dida auth diagnosis = %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("dida MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "stripe"
+type = "http"
+url = "https://mcp.stripe.com"
+headers = { Authorization = "Bearer ${STRIPE_TOKEN}" }
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "stripe" {
+			if s.AuthStatus != "none" {
+				t.Fatalf("stripe auth status = %q, want none; server = %+v", s.AuthStatus, s)
+			}
+			return
+		}
+	}
+	t.Fatalf("stripe MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "figma"
+type = "http"
+url = "https://mcp.figma.com/mcp"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	host := plugin.NewHost()
+	host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: host}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "figma" {
+			if s.Status != "failed" || s.AuthStatus != "required" || s.AuthURL != "https://mcp.figma.com/mcp" {
+				t.Fatalf("figma auth diagnosis = %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "figma"
+type = "http"
+url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main"
+headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" }
+env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" }
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	host := plugin.NewHost()
+	host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: host}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.ClearMCPServerAuthentication("figma"); err != nil {
+		t.Fatalf("ClearMCPServerAuthentication: %v", err)
+	}
+	if failures := host.Failures(); len(failures) != 0 {
+		t.Fatalf("failure should be cleared: %+v", failures)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	p := cfg.Plugins[0]
+	if p.URL != "https://mcp.figma.com/mcp?workspace=main" {
+		t.Fatalf("url = %q", p.URL)
+	}
+	if _, ok := p.Headers["Authorization"]; ok {
+		t.Fatalf("auth header should be removed: %v", p.Headers)
+	}
+	if p.Headers["X-Org"] != "team" {
+		t.Fatalf("ordinary header should be preserved: %v", p.Headers)
+	}
+	if _, ok := p.Env["FIGMA_TOKEN"]; ok {
+		t.Fatalf("auth env should be removed: %v", p.Env)
+	}
+	if p.Env["DEBUG"] != "1" {
+		t.Fatalf("ordinary env should be preserved: %v", p.Env)
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "figma" {
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" {
+				t.Fatalf("figma should return to background possible auth: %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.UpdateMCPServer("playwright", MCPServerInput{
+		Name:      "playwright",
+		Transport: "stdio",
+		Command:   "node",
+		Args:      []string{"server.js"},
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Plugins[0].Command; got != "node" {
+		t.Fatalf("updated command = %q, want node", got)
+	}
+	if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
+		t.Fatalf("env TOKEN = %q, want preserved env", got)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
+	if !ok {
+		t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
+	}
+	if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
+		t.Fatalf("user plugin after migration = %+v", userPlugin)
+	}
+	if userPlugin.Tier != "" {
+		t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
+	}
+	projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
+	if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
+		t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "playwright" {
+			if s.Status != "failed" {
+				t.Fatalf("updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v", s.Status, s)
+			}
+			if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
+				t.Fatalf("server command not refreshed: %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := t.TempDir()
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+	app.activeTab().disabledMCP["playwright"] = ServerView{}
+
+	if err := app.UpdateMCPServer("playwright", MCPServerInput{
+		Name:      "playwright",
+		Transport: "stdio",
+		Command:   "npx -y @modelcontextprotocol/server-filesystem .",
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	p := cfg.Plugins[0]
+	if p.Command != "npx" {
+		t.Fatalf("command = %q, want npx", p.Command)
+	}
+	if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") {
+		t.Fatalf("args = %v", p.Args)
+	}
+}
+
+func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "npx"
+tier = "background"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.UpdateMCPServer("broken", MCPServerInput{
+		Name:      "broken",
+		Transport: "stdio",
+		Command:   "reasonix-missing-mcp-binary",
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer should persist config even when reconnect fails: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Plugins[0].Command; got != "reasonix-missing-mcp-binary" {
+		t.Fatalf("updated command = %q, want missing binary", got)
+	}
+	if got := cfg.Plugins[0].Tier; got != "" {
+		t.Fatalf("updated tier = %q, want migrated empty", got)
+	}
+	if !mcpFailed(app.activeCtrl(), "broken") {
+		t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			if s.Status != "failed" {
+				t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
+			}
+			if s.Command != "reasonix-missing-mcp-binary" || s.Tier != "background" {
+				t.Fatalf("server config not refreshed after failed reconnect: %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "codegraph"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	reg := tool.NewRegistry()
+	reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	foundIdle := false
+	for _, s := range view.Servers {
+		if s.Name == "codegraph" {
+			foundIdle = true
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
+				t.Fatalf("initial codegraph server = %+v, want automatic idle background state", s)
+			}
+		}
+	}
+	if !foundIdle {
+		t.Fatalf("codegraph missing before reconnect: %+v", view.Servers)
+	}
+	if _, ok := reg.Get("mcp__codegraph__connect"); !ok {
+		t.Fatal("test setup expected stale codegraph connect placeholder")
+	}
+
+	if err := app.ReconnectMCPServer("codegraph"); err == nil || !strings.Contains(err.Error(), "command is required") {
+		t.Fatalf("ReconnectMCPServer error = %v, want missing command", err)
+	}
+	if _, ok := reg.Get("mcp__codegraph__connect"); ok {
+		t.Fatalf("stale codegraph placeholder still registered after reconnect failure; names=%v", reg.Names())
+	}
+	if !mcpFailed(app.activeCtrl(), "codegraph") {
+		t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded", app.activeCtrl().Host().Failures())
+	}
+
+	view = app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "codegraph" {
+			if s.Status != "failed" || s.Error == "" {
+				t.Fatalf("codegraph after failed reconnect = %+v, want failed with error", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
+}
+
+func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetMCPServerTier("broken", "background"); err != nil {
+		t.Fatalf("SetMCPServerTier legacy binding: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Plugins[0].Tier; got != "" {
+		t.Fatalf("saved tier = %q, want migrated empty", got)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
+	if !ok {
+		t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
+	}
+	if userPlugin.Tier != "" {
+		t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
+	}
+	projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
+	if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
+		t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
+	}
+	if !mcpFailed(app.activeCtrl(), "broken") {
+		t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			if s.Status != "failed" {
+				t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
+			}
+			if s.Tier != "background" {
+				t.Fatalf("server tier = %q, want background so radio selection does not jump back", s.Tier)
+			}
+			return
+		}
+	}
+	t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(newBackgroundJobController(t, "mcp-tier-job"), "")
+
+	err := app.SetMCPServerTier("broken", "background")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetMCPServerTier with background job error = %v, want active-work guard", err)
+	}
+	data, readErr := os.ReadFile(config.UserConfigPath())
+	if readErr != nil {
+		t.Fatalf("read config: %v", readErr)
+	}
+	if !strings.Contains(string(data), `tier = "lazy"`) {
+		t.Fatalf("plugin config changed after rejected tier update:\n%s", data)
+	}
+}
+
+func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+tier = "eager"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+	recordMCPFailure(app.activeCtrl(), config.PluginEntry{
+		Name:    "broken",
+		Command: "reasonix-missing-mcp-binary",
+		Tier:    "eager",
+	}, errors.New("connect: missing binary"))
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			if s.Status != "failed" {
+				t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
+			}
+			if s.Tier != "background" {
+				t.Fatalf("server tier = %q, want migrated background default", s.Tier)
+			}
+			if !s.Configured {
+				t.Fatalf("server configured = false, want true; server = %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	activeEvents := make(chan event.Event, 16)
+	inactiveEvents := make(chan event.Event, 16)
+	activeCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})
+	inactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})
+	defer activeCtrl.Close()
+	defer inactiveCtrl.Close()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"active":   {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
+			"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
+		},
+		tabOrder:    []string{"active", "inactive"},
+		activeTabID: "active",
+	}
+
+	app.RunShellForTab("inactive", "echo route-test")
+
+	sawDispatch := false
+	deadline := time.After(3 * time.Second)
+	for {
+		select {
+		case e := <-inactiveEvents:
+			if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, "route-test") {
+				sawDispatch = true
+			}
+			if e.Kind == event.TurnDone {
+				if !sawDispatch {
+					t.Fatal("inactive tab finished without receiving shell dispatch")
+				}
+				select {
+				case active := <-activeEvents:
+					t.Fatalf("active tab received event for inactive shell: %+v", active)
+				default:
+				}
+				return
+			}
+		case <-deadline:
+			t.Fatal("timed out waiting for inactive shell turn")
+		}
+	}
+}
+
+func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping shell cancellation integration test in short mode")
+	}
+
+	isolateDesktopUserDirs(t)
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	globalRoot := t.TempDir()
+	shellEvents := make(chan event.Event, 64)
+	projectEvents := make(chan event.Event, 64)
+	globalEvents := make(chan event.Event, 64)
+	shellCtrl := control.New(control.Options{
+		Sink:          event.FuncSink(func(e event.Event) { shellEvents <- e }),
+		WorkspaceRoot: projectA,
+	})
+	projectCtrl := control.New(control.Options{
+		Sink:          event.FuncSink(func(e event.Event) { projectEvents <- e }),
+		WorkspaceRoot: projectB,
+	})
+	globalCtrl := control.New(control.Options{
+		Sink:          event.FuncSink(func(e event.Event) { globalEvents <- e }),
+		WorkspaceRoot: globalRoot,
+	})
+	defer shellCtrl.Close()
+	defer projectCtrl.Close()
+	defer globalCtrl.Close()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"shell":     {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
+			"project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
+			"global":    {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
+		},
+		tabOrder:    []string{"shell", "project-b", "global"},
+		activeTabID: "shell",
+	}
+
+	marker := "shell-route-marker.txt"
+	if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
+		t.Fatalf("RunShellForTab: %v", err)
+	}
+	waitForShellDispatch(t, shellEvents, marker)
+	waitForFile(t, filepath.Join(projectA, marker), "shell")
+
+	for i := 0; i < 8; i++ {
+		if err := app.SetActiveTab("project-b"); err != nil {
+			t.Fatalf("SetActiveTab(project-b): %v", err)
+		}
+		if err := app.SetActiveTab("global"); err != nil {
+			t.Fatalf("SetActiveTab(global): %v", err)
+		}
+		if err := app.SetActiveTab("shell"); err != nil {
+			t.Fatalf("SetActiveTab(shell): %v", err)
+		}
+	}
+	if err := app.SetActiveTab("project-b"); err != nil {
+		t.Fatalf("SetActiveTab(project-b final): %v", err)
+	}
+	app.CancelTab("shell")
+
+	cancelled := false
+	deadline := time.After(15 * time.Second)
+	for {
+		select {
+		case e := <-shellEvents:
+			if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
+				cancelled = e.Tool.Err != ""
+			}
+			if e.Kind == event.TurnDone {
+				if !cancelled {
+					t.Fatal("shell tab finished without a cancelled shell result")
+				}
+				if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
+					t.Fatalf("shell marker appeared in project-b workspace: %v", err)
+				}
+				if got := activeTabIDForTest(app); got != "project-b" {
+					t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
+				}
+				assertNoEvents(t, projectEvents, "project-b")
+				assertNoEvents(t, globalEvents, "global")
+				return
+			}
+		case <-deadline:
+			t.Fatal("timed out waiting for shell tab cancellation")
+		}
+	}
+}
+
+func longRunningMarkerCommand(marker string) string {
+	if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
+		return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
+	}
+	return fmt.Sprintf("printf shell > %s; sleep 30", marker)
+}
+
+func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
+	t.Helper()
+	deadline := time.After(5 * time.Second)
+	for {
+		select {
+		case e := <-ch:
+			if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
+				return
+			}
+		case <-deadline:
+			t.Fatal("timed out waiting for shell dispatch")
+		}
+	}
+}
+
+func activeTabIDForTest(app *App) string {
+	app.mu.RLock()
+	defer app.mu.RUnlock()
+	return app.activeTabID
+}
+
+func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
+	t.Helper()
+	select {
+	case e := <-ch:
+		t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
+	default:
+	}
+}
+
+type blockingRunner struct {
+	started chan struct{}
+	release chan struct{}
+}
+
+func (r *blockingRunner) Run(ctx context.Context, _ string) error {
+	close(r.started)
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-r.release:
+		return nil
+	}
+}
+
+func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {
+	t.Helper()
+	started := make(chan struct{})
+	release := make(chan struct{})
+	jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) {
+		close(started)
+		<-ctx.Done()
+		<-release
+		return "", ctx.Err()
+	})
+	select {
+	case <-started:
+	case <-time.After(2 * time.Second):
+		t.Fatal("background job never started")
+	}
+	released := false
+	return func() {
+		if released {
+			return
+		}
+		released = true
+		close(release)
+	}
+}
+
+func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
+	t.Helper()
+	deadline := time.Now().Add(time.Second)
+	for ctrl.Running() {
+		if time.Now().After(deadline) {
+			t.Fatal("controller still running")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func newBackgroundJobController(t *testing.T, label string) *control.Controller {
+	t.Helper()
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, label+".jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	t.Cleanup(ctrl.Close)
+	jm.StartForSession(agent.BranchID(path), "bash", label, func(ctx context.Context, _ io.Writer) (string, error) {
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+	return ctrl
+}
+
+func hasLevel(levels []string, want string) bool {
+	for _, level := range levels {
+		if level == want {
+			return true
+		}
+	}
+	return false
+}
+
+func hasCommand(cmds []CommandInfo, name string) bool {
+	for _, cmd := range cmds {
+		if cmd.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func hasDirEntry(entries []DirEntry, name string) bool {
+	for _, entry := range entries {
+		if entry.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
+	app := &App{tabs: map[string]*WorkspaceTab{}}
+	if err := app.NewSession(); err == nil {
+		t.Error("NewSession with no controller must surface an error, not silently no-op")
+	}
+	if err := app.ClearSession(); err == nil {
+		t.Error("ClearSession with no controller must surface an error")
+	}
+
+	app = &App{
+		tabs:        map[string]*WorkspaceTab{"t1": {ID: "t1", StartupErr: "boot exploded"}},
+		activeTabID: "t1",
+	}
+	err := app.NewSession()
+	if err == nil || !strings.Contains(err.Error(), "boot exploded") {
+		t.Errorf("error should carry the tab's startup failure, got %v", err)
+	}
+}
+
+// --- Prompt history scanning tests ------------------------------------------
+
+func identityPromptDisplay(text string) string { return text }
+
+// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format
+// {"kind":"user.message","text":"..."} is correctly extracted.
+func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"}
+{"kind":"user.message","text":"second prompt"}
+{"kind":"model.final","content":"response"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "hello world" {
+		t.Errorf("expected 'hello world', got %q", entries[0].Text)
+	}
+	if entries[1].Text != "second prompt" {
+		t.Errorf("expected 'second prompt', got %q", entries[1].Text)
+	}
+	if entries[0].Turn != 0 || entries[1].Turn != 1 {
+		t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn)
+	}
+	if entries[0].SessionPath != path {
+		t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath)
+	}
+}
+
+// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event
+// format {"type":"user.message","text":"..."} is correctly extracted.
+func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"}
+{"type":"model.final","content":"response"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].Text != "v0 prompt" {
+		t.Errorf("expected 'v0 prompt', got %q", entries[0].Text)
+	}
+}
+
+// TestCollectPromptHistoryEntriesProviderMessage verifies that the current
+// provider.Message format {"role":"user","content":"..."} is correctly extracted.
+func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"}
+{"role":"assistant","content":"response"}
+{"role":"user","content":"another prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "hello from provider" {
+		t.Errorf("expected 'hello from provider', got %q", entries[0].Text)
+	}
+	if entries[1].Text != "another prompt" {
+		t.Errorf("expected 'another prompt', got %q", entries[1].Text)
+	}
+}
+
+// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the
+// same file are extracted.
+func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"}
+{"role":"user","content":"modern prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "legacy prompt" {
+		t.Errorf("expected 'legacy prompt', got %q", entries[0].Text)
+	}
+	if entries[1].Text != "modern prompt" {
+		t.Errorf("expected 'modern prompt', got %q", entries[1].Text)
+	}
+}
+
+func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)
+	if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123}
+{"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].At != 1800000000123 {
+		t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At)
+	}
+	if entries[1].At != rfcTime.UnixMilli() {
+		t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli())
+	}
+}
+
+// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses
+// the user-visible prompt text, not the controller-expanded model input.
+func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	expanded := "\nSaved memory\n\n\nvisible prompt"
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].Text != "visible prompt" {
+		t.Errorf("expected visible prompt, got %q", entries[0].Text)
+	}
+}
+
+func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"}
+{"role":"user","content":"real prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].Text != "real prompt" {
+		t.Errorf("expected real prompt, got %q", entries[0].Text)
+	}
+}
+
+// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only
+// assistant/tool messages returns no entries.
+func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"}
+{"kind":"tool.result","output":"done"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 0 {
+		t.Errorf("expected 0 entries, got %d", len(entries))
+	}
+}
+
+// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file
+// returns no entries without error.
+func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "empty.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 0 {
+		t.Errorf("expected 0 entries, got %d", len(entries))
+	}
+}
+
+// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans
+// multiple JSONL files and returns prompts newest-first.
+func TestScanPromptHistoryFromDir(t *testing.T) {
+	app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}}
+	_ = app
+
+	dir := t.TempDir()
+	// Write two session files with different mtimes (sleep to ensure ordering).
+	if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(10 * time.Millisecond)
+	if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	// Newest-first: "newer prompt" should be first.
+	if entries[0].Text != "newer prompt" {
+		t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text)
+	}
+	if entries[1].Text != "older prompt" {
+		t.Errorf("expected 'older prompt' second, got %q", entries[1].Text)
+	}
+}
+
+func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	early := filepath.Join(dir, "early.jsonl")
+	late := filepath.Join(dir, "late.jsonl")
+
+	if err := os.WriteFile(early, []byte(fmt.Sprintf(`{"role":"user","content":"early first","time":%d}
+{"role":"assistant","content":"ok"}
+{"role":"user","content":"early second","time":%d}
+`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(late, []byte(fmt.Sprintf(`{"role":"user","content":"late newest","time":%d}
+`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	// Invert file mtimes: session activity should keep each session grouped
+	// before event timestamps are considered within that session.
+	if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 3 {
+		t.Fatalf("expected 3 entries, got %d", len(entries))
+	}
+	want := []string{"early second", "early first", "late newest"}
+	for i, w := range want {
+		if entries[i].Text != w {
+			t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	early := filepath.Join(dir, "early.jsonl")
+	late := filepath.Join(dir, "late.jsonl")
+
+	if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"}
+{"role":"assistant","content":"ok"}
+{"role":"user","content":"early second"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{
+		CreatedAt: base,
+		UpdatedAt: base.Add(time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{
+		CreatedAt: base.Add(time.Minute),
+		UpdatedAt: base.Add(2 * time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+	// Invert file mtimes: branch UpdatedAt should be the activity clock.
+	if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 3 {
+		t.Fatalf("expected 3 entries, got %d", len(entries))
+	}
+	want := []string{"late newest", "early second", "early first"}
+	for i, w := range want {
+		if entries[i].Text != w {
+			t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	empty := filepath.Join(dir, "empty.jsonl")
+	real := filepath.Join(dir, "real.jsonl")
+
+	if err := os.WriteFile(empty, nil, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{
+		CreatedAt: base,
+		UpdatedAt: base.Add(time.Hour),
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{
+		CreatedAt: base,
+		UpdatedAt: base,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 || entries[0].Text != "real prompt" {
+		t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries)
+	}
+}
+
+func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {
+	dir := t.TempDir()
+	current := filepath.Join(dir, "current.jsonl")
+	other := filepath.Join(dir, "other.jsonl")
+	if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"}
+{"role":"assistant","content":"ok"}
+{"role":"user","content":"current second"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
+		CreatedAt: now,
+		UpdatedAt: now,
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
+		CreatedAt: now.Add(time.Minute),
+		UpdatedAt: now.Add(time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
+	defer ctrl.Close()
+	app.setTestCtrl(ctrl, "")
+
+	result, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(result.Entries) != 3 {
+		t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries)
+	}
+	want := []string{"current second", "current first", "other newest"}
+	for i, w := range want {
+		if result.Entries[i].Text != w {
+			t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {
+	dir := t.TempDir()
+	current := filepath.Join(dir, "current.jsonl")
+	other := filepath.Join(dir, "other.jsonl")
+	var lines []byte
+	for i := range 55 {
+		lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"current %d"}
+`, i))...)
+	}
+	if err := os.WriteFile(current, lines, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
+		CreatedAt: now,
+		UpdatedAt: now,
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
+		CreatedAt: now.Add(time.Minute),
+		UpdatedAt: now.Add(time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
+	defer ctrl.Close()
+	app.setTestCtrl(ctrl, "")
+
+	result, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(result.Entries) != promptHistoryPageLimit {
+		t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries))
+	}
+	if result.Entries[0].Text != "current 54" {
+		t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text)
+	}
+	if result.Entries[len(result.Entries)-1].Text != "current 5" {
+		t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text)
+	}
+	if !result.HasOlder || result.OlderCursor == "" {
+		t.Fatalf("first page should expose an older cursor: %+v", result)
+	}
+	for _, entry := range result.Entries {
+		if entry.Text == "other newest" {
+			t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries)
+		}
+	}
+
+	nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})
+	if err != nil {
+		t.Fatal(err)
+	}
+	next, err := app.ScanPromptHistory(string(nextRequest))
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"}
+	if len(next.Entries) != len(want) {
+		t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want))
+	}
+	for i, w := range want {
+		if next.Entries[i].Text != w {
+			t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	var lines []byte
+	for i := range 250 {
+		lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"prompt %d"}
+`, i))...)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 250 {
+		t.Fatalf("expected 250 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "prompt 249" {
+		t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text)
+	}
+}
+
+// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.
+func TestScanPromptHistoryFromDirEmpty(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 0 {
+		t.Errorf("expected 0 entries, got %d", len(entries))
+	}
+}
+
+// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil
+// on cache hit (nonce matches).
+func TestScanPromptHistoryCacheHit(t *testing.T) {
+	app := &App{tabs: map[string]*WorkspaceTab{}}
+	result, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	nonce := result.Nonce
+	if nonce == "" {
+		t.Error("expected a non-empty nonce on first call")
+	}
+
+	// Second call with the same nonce should be a cache hit (nil entries).
+	result2, err := app.ScanPromptHistory(nonce)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result2.Entries != nil {
+		t.Error("expected nil entries on cache hit")
+	}
+	if result2.Nonce != nonce {
+		t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce)
+	}
+}
+
+func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {
+	dirA := t.TempDir()
+	dirB := t.TempDir()
+	pathA := filepath.Join(dirA, "a.jsonl")
+	pathB := filepath.Join(dirB, "b.jsonl")
+	if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"})
+	ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"})
+	defer ctrlA.Close()
+	defer ctrlB.Close()
+
+	app.setTestCtrl(ctrlA, "")
+	first, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" {
+		t.Fatalf("first entries = %+v, want workspace A", first.Entries)
+	}
+
+	app.setTestCtrl(ctrlB, "")
+	second, err := app.ScanPromptHistory(first.Nonce)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.Entries == nil {
+		t.Fatal("expected rescan after session dir changes, got cache hit")
+	}
+	if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" {
+		t.Fatalf("second entries = %+v, want workspace B", second.Entries)
+	}
+}
+
+func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {
+	dir := t.TempDir()
+	pathA := filepath.Join(dir, "a.jsonl")
+	pathB := filepath.Join(dir, "b.jsonl")
+	if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"})
+	ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"})
+	defer ctrlA.Close()
+	defer ctrlB.Close()
+
+	app.setTestCtrl(ctrlA, "")
+	first, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" {
+		t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries)
+	}
+
+	app.setTestCtrl(ctrlB, "")
+	second, err := app.ScanPromptHistory(first.Nonce)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.Entries == nil {
+		t.Fatal("expected rescan after session path changes, got cache hit")
+	}
+	if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" {
+		t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries)
+	}
+}
diff --git a/desktop/appicon_asset_test.go b/desktop/appicon_asset_test.go
new file mode 100644
index 0000000..0f2027e
--- /dev/null
+++ b/desktop/appicon_asset_test.go
@@ -0,0 +1,183 @@
+package main
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"os"
+	"testing"
+)
+
+func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
+	f, err := os.Open("build/appicon.png")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+
+	img, err := png.Decode(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assertFullCanvasRoundedIcon(t, img, 1024)
+}
+
+func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
+	for _, size := range []int{16, 24, 32, 48, 64, 256} {
+		t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
+			img := decodeICOImage(t, "build/windows/icon.ico", size)
+			assertFullCanvasRoundedIcon(t, img, size)
+		})
+	}
+}
+
+func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
+	t.Helper()
+
+	bounds := img.Bounds()
+	if bounds.Dx() != size || bounds.Dy() != size {
+		t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
+	}
+
+	corners := []struct {
+		name string
+		x    int
+		y    int
+	}{
+		{"top-left", bounds.Min.X, bounds.Min.Y},
+		{"top-right", bounds.Max.X - 1, bounds.Min.Y},
+		{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
+		{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
+	}
+	for _, corner := range corners {
+		_, _, _, a := img.At(corner.x, corner.y).RGBA()
+		if a > 0xff {
+			t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
+		}
+	}
+
+	_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
+	if centerAlpha == 0 {
+		t.Fatal("app icon center must contain visible artwork")
+	}
+
+	edgePoints := []struct {
+		name string
+		x    int
+		y    int
+	}{
+		{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
+		{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
+		{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
+		{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
+	}
+	for _, point := range edgePoints {
+		_, _, _, a := img.At(point.x, point.y).RGBA()
+		if a == 0 {
+			t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
+		}
+		assertReasonixBlue(t, point.name, img.At(point.x, point.y))
+	}
+}
+
+func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
+	t.Helper()
+
+	r16, g16, b16, _ := colorValue.RGBA()
+	r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
+	if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
+		t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
+	}
+}
+
+func near(got, want uint8, tolerance uint8) bool {
+	if got > want {
+		return got-want <= tolerance
+	}
+	return want-got <= tolerance
+}
+
+func decodeICOImage(t *testing.T, path string, size int) image.Image {
+	t.Helper()
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	r := bytes.NewReader(data)
+
+	var header struct {
+		Reserved uint16
+		Type     uint16
+		Count    uint16
+	}
+	if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
+		t.Fatal(err)
+	}
+	if header.Reserved != 0 || header.Type != 1 {
+		t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
+	}
+
+	type iconEntry struct {
+		Width       uint8
+		Height      uint8
+		ColorCount  uint8
+		Reserved    uint8
+		Planes      uint16
+		BitCount    uint16
+		BytesInRes  uint32
+		ImageOffset uint32
+	}
+
+	entries := make([]iconEntry, header.Count)
+	for i := range entries {
+		if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
+	targetIndex := -1
+	for i, entry := range entries {
+		width := int(entry.Width)
+		height := int(entry.Height)
+		if width == 0 {
+			width = 256
+		}
+		if height == 0 {
+			height = 256
+		}
+		if width != height {
+			t.Fatalf("ICO image must be square, got %dx%d", width, height)
+		}
+		if _, ok := expectedSizes[width]; ok {
+			expectedSizes[width] = true
+		}
+		if width == size {
+			targetIndex = i
+		}
+	}
+	for expectedSize, found := range expectedSizes {
+		if !found {
+			t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
+		}
+	}
+	if targetIndex < 0 {
+		t.Fatalf("ICO is missing %dx%d image", size, size)
+	}
+
+	entry := entries[targetIndex]
+	end := int(entry.ImageOffset + entry.BytesInRes)
+	if end > len(data) {
+		t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
+	}
+	img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
+	if err != nil {
+		t.Fatal(err)
+	}
+	return img
+}
diff --git a/desktop/attach_dropped_test.go b/desktop/attach_dropped_test.go
new file mode 100644
index 0000000..9c3cbc2
--- /dev/null
+++ b/desktop/attach_dropped_test.go
@@ -0,0 +1,427 @@
+package main
+
+import (
+	"context"
+	"encoding/base64"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+)
+
+const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+
+func TestWorkspaceRelativeIn(t *testing.T) {
+	root := t.TempDir()
+
+	if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
+		t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
+	}
+	if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
+		t.Fatal("a path above the workspace must not resolve as in-tree")
+	}
+}
+
+func TestIsImageExt(t *testing.T) {
+	for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
+		if !isImageExt(p) {
+			t.Errorf("%q should be an image extension", p)
+		}
+	}
+	for _, p := range []string{"notes.pdf", "main.go", "noext"} {
+		if isImageExt(p) {
+			t.Errorf("%q should not be an image extension", p)
+		}
+	}
+}
+
+func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectRoot := t.TempDir()
+	if err := os.Chdir(projectRoot); err != nil {
+		t.Fatal(err)
+	}
+	projectRoot, _ = os.Getwd()
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: projectRoot},
+		},
+		activeTabID: "project",
+	}
+
+	got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
+	if err != nil {
+		t.Fatalf("SavePastedImage: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
+		t.Fatalf("pasted image should be saved under active workspace: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
+		t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
+	}
+	preview, err := app.AttachmentDataURL(got)
+	if err != nil {
+		t.Fatalf("AttachmentDataURL: %v", err)
+	}
+	if !strings.HasPrefix(preview, "data:image/png;base64,") {
+		t.Fatalf("preview = %q, want png data URL", preview)
+	}
+}
+
+func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	sessionDirA := desktopSessionDir(projectA)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
+		},
+		activeTabID: "project",
+	}
+
+	got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
+	if err != nil {
+		t.Fatalf("SavePastedImage: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
+		t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
+		t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
+	}
+	if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
+	}
+}
+
+func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectRoot := t.TempDir()
+	if err := os.Chdir(projectRoot); err != nil {
+		t.Fatal(err)
+	}
+	projectRoot, _ = os.Getwd()
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: projectRoot},
+		},
+		activeTabID: "project",
+	}
+	if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	target := filepath.Join(projectRoot, "sub", "notes.txt")
+	if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := app.AttachDropped(target)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
+		t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
+	}
+}
+
+func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectRoot := t.TempDir()
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: projectRoot},
+		},
+		activeTabID: "project",
+	}
+	raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
+	if err != nil {
+		t.Fatal(err)
+	}
+	outside := filepath.Join(t.TempDir(), "shot.png")
+	if err := os.WriteFile(outside, raw, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := app.AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
+		t.Fatalf("got %+v, want png attachment", got)
+	}
+	if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
+		t.Fatalf("dropped image should be saved under active workspace: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
+		t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
+	}
+	if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
+		t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
+	}
+}
+
+func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	root := t.TempDir()
+	if err := os.Chdir(root); err != nil {
+		t.Fatal(err)
+	}
+	cwd, _ := os.Getwd()
+	if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	target := filepath.Join(cwd, "sub", "notes.txt")
+	if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := (&App{}).AttachDropped(target)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
+		t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
+	}
+}
+
+func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	outside := filepath.Join(t.TempDir(), "report.pdf")
+	if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	root := t.TempDir()
+	if err := os.Chdir(root); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := (&App{}).AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
+		t.Fatalf("got %+v, want copied pdf attachment", got)
+	}
+}
+
+func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	root := t.TempDir()
+	if err := os.Chdir(root); err != nil {
+		t.Fatal(err)
+	}
+	cwd, _ := os.Getwd()
+	png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
+	if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
+		t.Fatalf("got %+v, want png attachment", got)
+	}
+	if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
+		t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
+	}
+}
+
+func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	workspace := t.TempDir()
+	outside := filepath.Join(t.TempDir(), "Folder With Spaces")
+	if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	expectedOutside := outside
+	if resolved, err := filepath.EvalSymlinks(outside); err == nil {
+		expectedOutside = resolved
+	}
+	expectedDisplayPath := filepath.ToSlash(expectedOutside)
+	if err := os.Chdir(workspace); err != nil {
+		t.Fatal(err)
+	}
+
+	ctrl := control.New(control.Options{WorkspaceRoot: workspace})
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
+		},
+		activeTabID: "project",
+	}
+
+	got, err := app.AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "workspace" || !got.IsDir {
+		t.Fatalf("got %+v, want workspace directory ref", got)
+	}
+	if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
+		t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
+	}
+	if got.DisplayPath != expectedDisplayPath {
+		t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
+	}
+
+	block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
+	if len(errs) != 0 {
+		t.Fatalf("ResolveScopedRefs errors = %v", errs)
+	}
+	if !strings.Contains(block, ``) ||
+		!strings.Contains(block, "sub/") ||
+		!strings.Contains(block, "sub/notes.txt") {
+		t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
+	}
+}
+
+func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
+	cfg := config.Default()
+	cfg.DefaultModel = "test/test-model"
+	cfg.Desktop.ProviderAccess = []string{"test"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	outside := filepath.Join(t.TempDir(), "External")
+	if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
+	sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
+	oldCtrl := control.New(control.Options{
+		SessionDir:    sessionDirB,
+		SessionPath:   sessionPathB,
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+	app := NewApp()
+	app.readyHook = func() {}
+	tab := &WorkspaceTab{
+		ID:            "project",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       "topic_external_ref",
+		TopicTitle:    "External ref",
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		model:         "test/test-model",
+		Ctrl:          oldCtrl,
+		sink:          &tabEventSink{tabID: "project", app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	got, err := app.AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if tab.Ctrl == oldCtrl {
+		t.Fatal("stale controller was reused for external folder ref")
+	}
+	if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
+		t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
+	}
+	resolver, ok := tab.Ctrl.(interface {
+		ResolveScopedRefs(context.Context, string) (string, []string)
+	})
+	if !ok {
+		t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
+	}
+	block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
+	if len(errs) != 0 {
+		t.Fatalf("ResolveScopedRefs errors = %v", errs)
+	}
+	if !strings.Contains(block, "sub/notes.txt") {
+		t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
+	}
+}
diff --git a/desktop/autosave_warn_test.go b/desktop/autosave_warn_test.go
new file mode 100644
index 0000000..03a20b1
--- /dev/null
+++ b/desktop/autosave_warn_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+	"errors"
+	"testing"
+	"time"
+)
+
+func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
+	failure := errors.New("disk unhappy")
+
+	app.reportTabSnapshotError(tab, "autosave", failure)
+	tab.saveMu.Lock()
+	first := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if first.IsZero() {
+		t.Fatal("first autosave warning did not record its timestamp")
+	}
+
+	// Within the window: suppressed, timestamp untouched.
+	app.reportTabSnapshotError(tab, "autosave", failure)
+	tab.saveMu.Lock()
+	second := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if !second.Equal(first) {
+		t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
+	}
+
+	// Window expired: warns again.
+	tab.saveMu.Lock()
+	tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
+	tab.saveMu.Unlock()
+	app.reportTabSnapshotError(tab, "autosave", failure)
+	tab.saveMu.Lock()
+	third := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if !third.After(first) {
+		t.Fatal("expired window did not re-arm the autosave warning")
+	}
+
+	// Explicit user actions are never debounced (state untouched).
+	app.reportTabSnapshotError(tab, "changing model", failure)
+	tab.saveMu.Lock()
+	fourth := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if !fourth.Equal(third) {
+		t.Fatal("action-save warning must not consume the autosave debounce window")
+	}
+}
diff --git a/desktop/bot_bridge.go b/desktop/bot_bridge.go
new file mode 100644
index 0000000..b1ed5fc
--- /dev/null
+++ b/desktop/bot_bridge.go
@@ -0,0 +1,707 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+// errDriveBusy signals that a takeover drive could not start because the target
+// controller was already running a turn. The hub translates it into a
+// user-facing "session busy" message rather than a generic drive failure.
+var errDriveBusy = errors.New("desktop session busy")
+
+// botBridgeHub 是 bot 网关对桌面端的"上帝视角"桥(bot.DesktopBridge 的实现)。
+//
+// 职责边界(刻意保持窄):
+//   - 观察:tabEventSink.Emit 把每个桌面会话的事件旁路到 observe;hub 记录
+//     待审批/待回答项,并把审批请求、任务完成/出错推送给订阅聊天。
+//   - 遥控审批:/desktop approve|deny|answer 经 App.ApproveTab /
+//     AnswerQuestionForTab 按 tab 寻址回写。controller 侧幂等(先到者赢),
+//     桌面 UI 与远端并发应答互不干扰。
+//   - 不做:向桌面会话注入输入、抢占写 lease。将来的显式接管在
+//     bot.DesktopBridge 上扩展,底层走 internal/control 的接管端口。
+//
+// observe 跑在 controller 的事件 goroutine 上,绝不能做网络调用——通知统一
+// 进有界队列由 worker 异步发送,队列满时丢弃并告警。
+type botBridgeHub struct {
+	sessions   func() []bot.DesktopSessionInfo
+	approveTab func(tabID, id string, allow, session, persist bool)
+	answerTab  func(tabID, id string, answers []QuestionAnswer)
+	notify     func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
+	// drive 把一条远程文本提交为 tab 的新 turn,并把该 turn 的输出转发回 route。
+	drive func(tabID, text string, route bot.DesktopWatchRoute) error
+	// announce 往会话 transcript 里发一条 Notice,让桌面用户看到接管状态变化。
+	announce func(tabID, text string)
+	// persistWatchers 把订阅全集回写用户配置(bot.desktop_watchers)。
+	persistWatchers func(routes []bot.DesktopWatchRoute) error
+	// takeoverChanged 通知桌面前端刷新(TabMeta.RemoteControlled 变化)。
+	takeoverChanged func()
+	logger          *slog.Logger
+
+	mu       sync.Mutex
+	watchers map[string]bot.DesktopWatchRoute
+	pending  map[string]desktopPendingPrompt
+	// takeovers: tabID -> 驾驶该会话的聊天路由;takeoverTabs: routeKey -> tabID。
+	takeovers    map[string]bot.DesktopWatchRoute
+	takeoverTabs map[string]string
+	// watchSeq 单调递增,标记订阅快照的新旧;persist 时用它丢弃过期写入。
+	watchSeq uint64
+	// watchPersistDirty keeps a failed local mutation authoritative in memory;
+	// a later runtime refresh must not silently restore the older disk snapshot.
+	watchPersistDirty bool
+
+	// persistMu 串行化订阅落盘,并保证只写最新快照(见 SetWatch)。
+	persistMu      sync.Mutex
+	lastPersistSeq uint64
+
+	queue chan desktopBridgeNotification
+}
+
+type desktopPendingPrompt struct {
+	tabID     string
+	kind      string // "approval" | "ask"
+	tool      string
+	subject   string
+	questions []event.AskQuestion
+}
+
+// desktopBridgeNotification 是一条待推送的桌面事件。text 与 card 都按订阅路由
+// 现做,因此群聊(多用户)与私聊可给出不同详略——命令行/错误详情只发私聊,群里
+// 只给摘要。route 非 nil 时定向发给该聊天(不看 watch 订阅),用于接管收回等必达通知。
+type desktopBridgeNotification struct {
+	text  func(route bot.DesktopWatchRoute) string
+	card  func(route bot.DesktopWatchRoute) *bot.InteractiveCard
+	route *bot.DesktopWatchRoute
+}
+
+// isSharedChat 判断一个聊天是否为多用户场景(群/话题群/服务器频道)。私聊/单聊
+// 只有操作者本人,可安全展示命令行等敏感详情。
+func isSharedChat(ct bot.ChatType) bool {
+	return ct != bot.ChatDM && ct != bot.ChatDirect
+}
+
+func constText(s string) func(bot.DesktopWatchRoute) string {
+	return func(bot.DesktopWatchRoute) string { return s }
+}
+
+const (
+	botBridgeQueueSize     = 64
+	botBridgeSendTimeout   = 15 * time.Second
+	botBridgeSubjectLimit  = 200
+	botBridgePendingLimit  = 200
+	botBridgeErrTextLimit  = 300
+	botBridgePromptPreview = 500
+)
+
+// botBridgeDeps 打包 hub 对宿主(App)的全部依赖,便于测试注入。
+type botBridgeDeps struct {
+	sessions        func() []bot.DesktopSessionInfo
+	approveTab      func(tabID, id string, allow, session, persist bool)
+	answerTab       func(tabID, id string, answers []QuestionAnswer)
+	notify          func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
+	drive           func(tabID, text string, route bot.DesktopWatchRoute) error
+	announce        func(tabID, text string)
+	persistWatchers func(routes []bot.DesktopWatchRoute) error
+	takeoverChanged func()
+	logger          *slog.Logger
+}
+
+func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
+	logger := deps.logger
+	if logger == nil {
+		logger = slog.Default()
+	}
+	h := &botBridgeHub{
+		sessions:        deps.sessions,
+		approveTab:      deps.approveTab,
+		answerTab:       deps.answerTab,
+		notify:          deps.notify,
+		drive:           deps.drive,
+		announce:        deps.announce,
+		persistWatchers: deps.persistWatchers,
+		takeoverChanged: deps.takeoverChanged,
+		logger:          logger.With("component", "bot_bridge"),
+		watchers:        make(map[string]bot.DesktopWatchRoute),
+		pending:         make(map[string]desktopPendingPrompt),
+		takeovers:       make(map[string]bot.DesktopWatchRoute),
+		takeoverTabs:    make(map[string]string),
+		queue:           make(chan desktopBridgeNotification, botBridgeQueueSize),
+	}
+	go h.run()
+	return h
+}
+
+// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
+// 只做内存记账和入队,不做任何阻塞调用。
+func (h *botBridgeHub) observe(tabID string, e event.Event) {
+	switch e.Kind {
+	case event.ApprovalRequest:
+		h.mu.Lock()
+		h.rememberPendingLocked(e.Approval.ID, desktopPendingPrompt{
+			tabID:   tabID,
+			kind:    "approval",
+			tool:    e.Approval.Tool,
+			subject: truncateForBridge(e.Approval.Subject, botBridgeSubjectLimit),
+		})
+		watching := len(h.watchers) > 0
+		h.mu.Unlock()
+		if watching {
+			h.enqueue(h.approvalNotification(tabID, e.Approval))
+		}
+	case event.AskRequest:
+		h.mu.Lock()
+		h.rememberPendingLocked(e.Ask.ID, desktopPendingPrompt{
+			tabID:     tabID,
+			kind:      "ask",
+			questions: e.Ask.Questions,
+		})
+		watching := len(h.watchers) > 0
+		h.mu.Unlock()
+		if watching {
+			h.enqueue(h.askNotification(tabID, e.Ask))
+		}
+	case event.TurnDone:
+		h.mu.Lock()
+		for id, p := range h.pending {
+			if p.tabID == tabID {
+				delete(h.pending, id)
+			}
+		}
+		watching := len(h.watchers) > 0
+		h.mu.Unlock()
+		if !watching {
+			return
+		}
+		if e.Err != nil && strings.Contains(e.Err.Error(), "context canceled") {
+			// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
+			return
+		}
+		h.enqueue(h.turnDoneNotification(tabID, e.Err))
+	}
+}
+
+// rememberPendingLocked 记录待处理项;容量兜底防泄漏(正常路径 TurnDone 会清理)。
+func (h *botBridgeHub) rememberPendingLocked(id string, p desktopPendingPrompt) {
+	if strings.TrimSpace(id) == "" {
+		return
+	}
+	if len(h.pending) >= botBridgePendingLimit {
+		h.pending = make(map[string]desktopPendingPrompt)
+	}
+	h.pending[id] = p
+}
+
+func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
+	select {
+	case h.queue <- n:
+	default:
+		h.logger.Warn("desktop bridge notification queue full; dropping")
+	}
+}
+
+func (h *botBridgeHub) run() {
+	for n := range h.queue {
+		h.deliver(n)
+	}
+}
+
+func (h *botBridgeHub) deliver(n desktopBridgeNotification) {
+	h.mu.Lock()
+	var routes []bot.DesktopWatchRoute
+	if n.route != nil {
+		routes = []bot.DesktopWatchRoute{*n.route}
+	} else {
+		routes = h.watcherRoutesLocked()
+	}
+	notify := h.notify
+	h.mu.Unlock()
+	if notify == nil || len(routes) == 0 {
+		return
+	}
+	// Fan out per route: a single slow/hung connection must not hold the queue
+	// worker for its full timeout and back-pressure everyone else's approvals.
+	var wg sync.WaitGroup
+	for _, route := range routes {
+		wg.Add(1)
+		go func(route bot.DesktopWatchRoute) {
+			defer wg.Done()
+			msg := bot.OutboundMessage{
+				ChatID:   route.ChatID,
+				ChatType: route.ChatType,
+			}
+			if n.text != nil {
+				msg.Text = n.text(route)
+			}
+			if n.card != nil {
+				msg.Card = n.card(route)
+			}
+			ctx, cancel := context.WithTimeout(context.Background(), botBridgeSendTimeout)
+			defer cancel()
+			if _, err := notify(ctx, route.ConnectionID, route.Domain, msg); err != nil {
+				h.logger.Warn("desktop bridge notification send failed", "platform", route.Platform, "err", err)
+			}
+		}(route)
+	}
+	wg.Wait()
+}
+
+// tabLabel 把 tabID 解析成人类可读的会话名。
+func (h *botBridgeHub) tabLabel(tabID string) string {
+	if s, ok := h.sessionByTabID(tabID); ok {
+		if label := strings.TrimSpace(s.Label); label != "" {
+			return label
+		}
+		if title := strings.TrimSpace(s.Topic); title != "" {
+			return title
+		}
+	}
+	return "(未命名会话)"
+}
+
+func (h *botBridgeHub) sessionByTabID(tabID string) (bot.DesktopSessionInfo, bool) {
+	if h.sessions == nil {
+		return bot.DesktopSessionInfo{}, false
+	}
+	for _, s := range h.sessions() {
+		if s.TabID == tabID {
+			return s, true
+		}
+	}
+	return bot.DesktopSessionInfo{}, false
+}
+
+func (h *botBridgeHub) approvalNotification(tabID string, approval event.Approval) desktopBridgeNotification {
+	label := h.tabLabel(tabID)
+	// The approval subject is the pending command line; only reveal it in a
+	// private chat. In a shared chat show the tool name and point the operator
+	// to the desktop / a DM instead of leaking the command to the whole group.
+	subjectFor := func(route bot.DesktopWatchRoute) string {
+		if isSharedChat(route.ChatType) {
+			return "(命令详情仅在桌面端或私聊显示)"
+		}
+		return truncateForBridge(approval.Subject, botBridgeSubjectLimit)
+	}
+	return desktopBridgeNotification{
+		text: func(route bot.DesktopWatchRoute) string {
+			return fmt.Sprintf("⚠️ 桌面会话「%s」需要批准操作\n工具: %s\n操作: %s\n\nID: `%s`\n用 /desktop approve %s 批准,/desktop deny %s 拒绝。桌面端先处理则以先到者为准。",
+				label, approval.Tool, subjectFor(route), approval.ID, approval.ID, approval.ID)
+		},
+		card: func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
+			return &bot.InteractiveCard{
+				Header: "桌面会话需要批准",
+				Elements: []bot.InteractiveCardElement{
+					{Tag: "markdown", Content: fmt.Sprintf("**会话**: %s\n\n**工具**: %s\n\n**操作**: %s\n\nID: `%s`", label, approval.Tool, subjectFor(route), approval.ID)},
+					{Tag: "action", Extra: map[string]any{
+						"actions": []map[string]any{
+							desktopCardButton("允许一次", "primary", "/desktop approve "+approval.ID, route),
+							desktopCardButton("拒绝", "danger", "/desktop deny "+approval.ID, route),
+						},
+					}},
+				},
+			}
+		},
+	}
+}
+
+func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridgeNotification {
+	label := h.tabLabel(tabID)
+	var b strings.Builder
+	fmt.Fprintf(&b, "❓ 桌面会话「%s」在等待回答:\n", label)
+	for i, q := range ask.Questions {
+		fmt.Fprintf(&b, "\n**%d. %s**\n", i+1, truncateForBridge(q.Prompt, botBridgePromptPreview))
+		for j, opt := range q.Options {
+			fmt.Fprintf(&b, "  %d. %s\n", j+1, opt.Label)
+		}
+	}
+	fmt.Fprintf(&b, "\nID: `%s`\n用 /desktop answer %s <选项编号或文本> 回答;桌面端先处理则以先到者为准。", ask.ID, ask.ID)
+	privateText := b.String()
+	sharedText := fmt.Sprintf("❓ 桌面会话「%s」正在等待回答(问题详情仅在桌面端或私聊显示)。\n\nID: `%s`", label, ask.ID)
+	textFor := func(route bot.DesktopWatchRoute) string {
+		if isSharedChat(route.ChatType) {
+			return sharedText
+		}
+		return privateText
+	}
+
+	var card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
+	if len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0 {
+		options := ask.Questions[0].Options
+		card = func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
+			if isSharedChat(route.ChatType) {
+				return nil
+			}
+			actions := make([]map[string]any, 0, len(options))
+			for i, opt := range options {
+				optLabel := strings.TrimSpace(opt.Label)
+				if optLabel == "" {
+					optLabel = fmt.Sprintf("选项 %d", i+1)
+				}
+				actions = append(actions, desktopCardButton(optLabel, "primary", fmt.Sprintf("/desktop answer %s %d", ask.ID, i+1), route))
+			}
+			return &bot.InteractiveCard{
+				Header: "桌面会话在等待回答",
+				Elements: []bot.InteractiveCardElement{
+					{Tag: "markdown", Content: privateText},
+					{Tag: "action", Extra: map[string]any{"actions": actions}},
+				},
+			}
+		}
+	}
+	return desktopBridgeNotification{text: textFor, card: card}
+}
+
+func (h *botBridgeHub) turnDoneNotification(tabID string, err error) desktopBridgeNotification {
+	label := h.tabLabel(tabID)
+	if err != nil {
+		// Error text can contain paths/tokens; only detail it in a private chat.
+		return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
+			if isSharedChat(route.ChatType) {
+				return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
+			}
+			return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(err.Error(), botBridgeErrTextLimit))
+		}}
+	}
+	return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
+}
+
+func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
+	return map[string]any{
+		"tag":  "button",
+		"text": map[string]string{"tag": "plain_text", "content": label},
+		"type": style,
+		"value": map[string]string{
+			"command":   command,
+			"chat_type": string(route.ChatType),
+		},
+	}
+}
+
+func truncateForBridge(s string, limit int) string {
+	s = strings.TrimSpace(s)
+	runes := []rune(s)
+	if len(runes) <= limit {
+		return s
+	}
+	return string(runes[:limit]) + "…"
+}
+
+// ---- bot.DesktopBridge 实现 ----
+
+func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
+	if h.sessions == nil {
+		return nil
+	}
+	sessions := h.sessions()
+	h.mu.Lock()
+	byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
+	for id, p := range h.pending {
+		byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
+	}
+	h.mu.Unlock()
+	for i := range sessions {
+		if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
+			sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
+			sessions[i].Pending = pend
+		}
+	}
+	return sessions
+}
+
+func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
+	h.mu.Lock()
+	if enable {
+		h.watchers[route.Key()] = route
+	} else {
+		delete(h.watchers, route.Key())
+	}
+	h.watchSeq++
+	h.watchPersistDirty = true
+	seq := h.watchSeq
+	routes := h.watcherRoutesLocked()
+	persist := h.persistWatchers
+	h.mu.Unlock()
+	if persist == nil {
+		return nil
+	}
+	// Serialize persists and drop stale ones: two concurrent SetWatch calls
+	// (different connections) compute snapshots under h.mu but write config
+	// outside it, so their writes could otherwise reorder and let an older
+	// snapshot clobber a newer one, silently losing a subscription.
+	h.persistMu.Lock()
+	defer h.persistMu.Unlock()
+	if seq <= h.lastPersistSeq {
+		return nil
+	}
+	if err := persist(routes); err != nil {
+		return err
+	}
+	h.lastPersistSeq = seq
+	h.mu.Lock()
+	if h.watchSeq == seq {
+		h.watchPersistDirty = false
+	}
+	h.mu.Unlock()
+	return nil
+}
+
+func (h *botBridgeHub) watcherVersion() uint64 {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	return h.watchSeq
+}
+
+// seedWatchers applies a config snapshot only if no watch command changed the
+// runtime after the config read began. Fresh external config edits still apply;
+// stale refreshes and failed local persists do not erase newer runtime state.
+func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
+	h.persistMu.Lock()
+	defer h.persistMu.Unlock()
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	if h.watchSeq != expectedSeq || h.watchPersistDirty {
+		return
+	}
+	h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
+	for _, r := range routes {
+		if strings.TrimSpace(r.ChatID) == "" {
+			continue
+		}
+		h.watchers[r.Key()] = r
+	}
+}
+
+func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
+	routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
+	for _, r := range h.watchers {
+		routes = append(routes, r)
+	}
+	sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
+	return routes
+}
+
+func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	_, ok := h.watchers[route.Key()]
+	return ok
+}
+
+func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
+	approvalID = strings.TrimSpace(approvalID)
+	h.mu.Lock()
+	p, ok := h.pending[approvalID]
+	if ok && p.kind == "approval" {
+		delete(h.pending, approvalID)
+	}
+	h.mu.Unlock()
+	if !ok || p.kind != "approval" {
+		return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
+	}
+	if h.approveTab == nil {
+		return "", fmt.Errorf("桌面端审批通道不可用。")
+	}
+	h.approveTab(p.tabID, approvalID, allow, false, false)
+	action := "批准"
+	if !allow {
+		action = "拒绝"
+	}
+	return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
+}
+
+func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	p, ok := h.pending[strings.TrimSpace(askID)]
+	if !ok || p.kind != "ask" {
+		return nil, false
+	}
+	return p.questions, true
+}
+
+func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
+	askID = strings.TrimSpace(askID)
+	h.mu.Lock()
+	p, ok := h.pending[askID]
+	if ok && p.kind == "ask" {
+		delete(h.pending, askID)
+	}
+	h.mu.Unlock()
+	if !ok || p.kind != "ask" {
+		return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
+	}
+	if h.answerTab == nil {
+		return "", fmt.Errorf("桌面端问答通道不可用。")
+	}
+	out := make([]QuestionAnswer, 0, len(answers))
+	for _, an := range answers {
+		out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
+	}
+	h.answerTab(p.tabID, askID, out)
+	return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
+}
+
+// ---- 显式接管 ----
+
+func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
+	tabID = strings.TrimSpace(tabID)
+	// DM only. In a group the binding is keyed on the group chat, so after an
+	// admin takes over, ANY allowlisted member's plain message would be diverted
+	// to drive the session — a privilege escalation past the admin gate that
+	// establishes the takeover. Restricting to DM keeps the driver identical to
+	// the operator who established it.
+	if route.ChatType != bot.ChatDM {
+		return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
+	}
+	session, ok := h.sessionByTabID(tabID)
+	if !ok {
+		return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
+	}
+	if session.Detached {
+		return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
+	}
+	h.mu.Lock()
+	if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
+		h.mu.Unlock()
+		return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
+	}
+	// 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
+	released := ""
+	if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
+		delete(h.takeovers, prev)
+		released = prev
+	}
+	h.takeovers[tabID] = route
+	h.takeoverTabs[route.Key()] = tabID
+	announce := h.announce
+	changed := h.takeoverChanged
+	h.mu.Unlock()
+	if announce != nil {
+		if released != "" {
+			announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
+		}
+		announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
+	}
+	if changed != nil {
+		changed()
+	}
+	label := h.tabLabel(tabID)
+	return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
+}
+
+func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
+	h.mu.Lock()
+	tabID, ok := h.takeoverTabs[route.Key()]
+	if ok {
+		delete(h.takeoverTabs, route.Key())
+		delete(h.takeovers, tabID)
+	}
+	announce := h.announce
+	changed := h.takeoverChanged
+	h.mu.Unlock()
+	if !ok {
+		return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
+	}
+	if announce != nil {
+		announce(tabID, "IM 远程接管已解除。")
+	}
+	if changed != nil {
+		changed()
+	}
+	return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
+}
+
+func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	return h.takeoverTabs[route.Key()]
+}
+
+func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
+	h.mu.Lock()
+	tabID := h.takeoverTabs[route.Key()]
+	h.mu.Unlock()
+	if tabID == "" {
+		return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
+	}
+	session, ok := h.sessionByTabID(tabID)
+	if !ok || session.Detached {
+		// 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
+		h.mu.Lock()
+		delete(h.takeoverTabs, route.Key())
+		delete(h.takeovers, tabID)
+		h.mu.Unlock()
+		if changed := h.takeoverChanged; changed != nil {
+			changed()
+		}
+		return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
+	}
+	if session.Running {
+		return "", h.busyError(tabID)
+	}
+	if h.drive == nil {
+		return "", fmt.Errorf("桌面端驱动通道不可用。")
+	}
+	if err := h.drive(tabID, text, route); err != nil {
+		if errors.Is(err, errDriveBusy) {
+			return "", h.busyError(tabID)
+		}
+		return "", fmt.Errorf("驱动失败: %v", err)
+	}
+	return "", nil
+}
+
+func (h *botBridgeHub) busyError(tabID string) error {
+	return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
+}
+
+// reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
+// 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
+func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
+	h.mu.Lock()
+	route, ok := h.takeovers[tabID]
+	if ok {
+		delete(h.takeovers, tabID)
+		delete(h.takeoverTabs, route.Key())
+	}
+	notify := h.notify
+	changed := h.takeoverChanged
+	h.mu.Unlock()
+	if !ok {
+		return
+	}
+	if changed != nil {
+		changed()
+	}
+	if notify == nil {
+		return
+	}
+	label := h.tabLabel(tabID)
+	// 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
+	h.enqueue(desktopBridgeNotification{
+		text:  constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
+		route: &route,
+	})
+}
+
+// remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
+func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	if len(h.takeovers) == 0 {
+		return nil
+	}
+	out := make(map[string]bool, len(h.takeovers))
+	for tabID := range h.takeovers {
+		out[tabID] = true
+	}
+	return out
+}
diff --git a/desktop/bot_bridge_app.go b/desktop/bot_bridge_app.go
new file mode 100644
index 0000000..7475c1e
--- /dev/null
+++ b/desktop/bot_bridge_app.go
@@ -0,0 +1,184 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+)
+
+// 本文件是 botBridgeHub 对 App 的全部胶水:会话枚举(含后台 detached)、
+// 按 tab 寻址的审批/问答/驱动、transcript 公告、订阅持久化。
+
+func (a *App) newBotBridge() *botBridgeHub {
+	return newBotBridgeHub(botBridgeDeps{
+		sessions:        a.bridgeSessions,
+		approveTab:      a.bridgeApprove,
+		answerTab:       a.bridgeAnswer,
+		notify:          a.botRuntime.SendToAdapter,
+		drive:           a.bridgeDrive,
+		announce:        a.bridgeAnnounce,
+		persistWatchers: a.bridgePersistWatchers,
+		takeoverChanged: a.emitProjectTreeChanged,
+		logger:          slog.Default(),
+	})
+}
+
+// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
+// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
+func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
+	tabs := a.ListTabs()
+	out := make([]bot.DesktopSessionInfo, 0, len(tabs)+4)
+	seen := make(map[string]bool, len(tabs))
+	for _, t := range tabs {
+		seen[t.ID] = true
+		out = append(out, bot.DesktopSessionInfo{
+			TabID:         t.ID,
+			Label:         t.Label,
+			Workspace:     t.WorkspaceName,
+			Topic:         t.TopicTitle,
+			Ready:         t.Ready,
+			Running:       t.Running,
+			PendingPrompt: t.PendingPrompt,
+		})
+	}
+	a.mu.RLock()
+	for _, tab := range a.detachedSessions {
+		if tab == nil || seen[tab.ID] {
+			continue
+		}
+		seen[tab.ID] = true
+		out = append(out, bot.DesktopSessionInfo{
+			TabID:    tab.ID,
+			Label:    tab.TopicTitle,
+			Topic:    tab.TopicTitle,
+			Ready:    tab.Ctrl != nil,
+			Running:  strings.TrimSpace(tab.ActivityStatus) != "",
+			Detached: true,
+		})
+	}
+	a.mu.RUnlock()
+	return out
+}
+
+// bridgeCtrlByTabID 解析可见与后台 detached 两张表(区别于 ctrlByTabID:
+// 那是前端语义,空 tabID 落到活跃 tab,且不看 detached)。
+func (a *App) bridgeCtrlByTabID(tabID string) control.SessionAPI {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if tab := a.tabByEventSinkIDLocked(tabID); tab != nil {
+		return tab.Ctrl
+	}
+	return nil
+}
+
+func (a *App) bridgeApprove(tabID, id string, allow, session, persist bool) {
+	if ctrl := a.bridgeCtrlByTabID(tabID); ctrl != nil {
+		ctrl.Approve(id, allow, session, persist)
+	}
+}
+
+func (a *App) bridgeAnswer(tabID, id string, answers []QuestionAnswer) {
+	ctrl := a.bridgeCtrlByTabID(tabID)
+	if ctrl == nil {
+		return
+	}
+	out := make([]event.AskAnswer, len(answers))
+	for i, an := range answers {
+		out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
+	}
+	ctrl.AnswerQuestion(id, out)
+}
+
+// bridgeAnnounce 往会话 transcript 发一条 Notice,桌面用户在聊天流里可见。
+func (a *App) bridgeAnnounce(tabID, text string) {
+	a.mu.RLock()
+	tab := a.tabByEventSinkIDLocked(tabID)
+	var sink *tabEventSink
+	if tab != nil {
+		sink = tab.sink
+	}
+	a.mu.RUnlock()
+	if sink == nil {
+		return
+	}
+	sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
+}
+
+// bridgeDrive 把远程文本提交为可见 tab 的新 turn,并为这一轮挂上事件转发器,
+// 让输出流回接管聊天(转发器在 TurnDone 自动卸载)。
+func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, false)
+	if err != nil {
+		if err == control.ErrTurnRunning {
+			return errDriveBusy
+		}
+		return err
+	}
+	if tab.sink == nil {
+		a.finishTabTurnStart(tab, nil)
+		return fmt.Errorf("会话事件通道不可用,无法驱动")
+	}
+	// A local submission may have reclaimed the tab while this drive was waiting
+	// for the per-tab admission gate. Revalidate ownership only after the gate is
+	// held, immediately before attaching the route-specific forwarder.
+	if a.botBridge == nil || a.botBridge.TakeoverTab(route) != tabID {
+		tab.sink.cancelTurnStart()
+		tab.turnStartMu.Unlock()
+		return fmt.Errorf("接管已解除,请重新接管会话")
+	}
+	target := botForwardTarget{
+		ConnID:   route.ConnectionID,
+		Domain:   route.Domain,
+		ChatID:   route.ChatID,
+		ChatType: route.ChatType,
+	}
+	generation := tab.sink.SetBotSink(newBotEventForwarder(a.botRuntime, []botForwardTarget{target}))
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitDisplay(text, text)
+	// Confirm the submit actually started a turn. If nothing is running now, the
+	// controller was rotating and the submit no-oped — detach this exact
+	// generation so a later turn's output does not leak.
+	if !a.finishTabTurnStart(tab, ctrl) {
+		tab.sink.clearBotSink(generation)
+		return errDriveBusy
+	}
+	return nil
+}
+
+// bridgePersistWatchers 把订阅全集回写用户配置(bot.desktop_watchers),
+// 桌面重启后由 refreshBotRuntime 重新种子。
+func (a *App) bridgePersistWatchers(routes []bot.DesktopWatchRoute) error {
+	return a.applyConfigOnly(func(c *config.Config) error {
+		watchers := make([]config.BotDesktopWatcherConfig, 0, len(routes))
+		for _, r := range routes {
+			watchers = append(watchers, config.BotDesktopWatcherConfig{
+				Platform:     string(r.Platform),
+				ConnectionID: r.ConnectionID,
+				Domain:       r.Domain,
+				ChatType:     string(r.ChatType),
+				ChatID:       r.ChatID,
+			})
+		}
+		c.Bot.DesktopWatchers = watchers
+		return nil
+	})
+}
+
+func bridgeRoutesFromConfig(watchers []config.BotDesktopWatcherConfig) []bot.DesktopWatchRoute {
+	routes := make([]bot.DesktopWatchRoute, 0, len(watchers))
+	for _, w := range watchers {
+		routes = append(routes, bot.DesktopWatchRoute{
+			Platform:     bot.Platform(strings.TrimSpace(w.Platform)),
+			ConnectionID: strings.TrimSpace(w.ConnectionID),
+			Domain:       strings.TrimSpace(w.Domain),
+			ChatType:     bot.ChatType(strings.TrimSpace(w.ChatType)),
+			ChatID:       strings.TrimSpace(w.ChatID),
+		})
+	}
+	return routes
+}
diff --git a/desktop/bot_bridge_test.go b/desktop/bot_bridge_test.go
new file mode 100644
index 0000000..c90c7b8
--- /dev/null
+++ b/desktop/bot_bridge_test.go
@@ -0,0 +1,605 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"strings"
+	"testing"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+type bridgeNotifyCall struct {
+	connectionID string
+	domain       string
+	msg          bot.OutboundMessage
+}
+
+type bridgeTestEnv struct {
+	hub        *botBridgeHub
+	notified   chan bridgeNotifyCall
+	approves   chan [2]string // [tabID, id+":"+allow]
+	answers    chan [2]string // [tabID, id]
+	driven     chan [2]string // [tabID, text]
+	announced  chan [2]string // [tabID, text]
+	persisted  chan []bot.DesktopWatchRoute
+	driveErr   error
+	persistErr error
+}
+
+func tabsToSessions(tabs []TabMeta) []bot.DesktopSessionInfo {
+	out := make([]bot.DesktopSessionInfo, 0, len(tabs))
+	for _, t := range tabs {
+		out = append(out, bot.DesktopSessionInfo{
+			TabID:         t.ID,
+			Label:         t.Label,
+			Workspace:     t.WorkspaceName,
+			Topic:         t.TopicTitle,
+			Ready:         t.Ready,
+			Running:       t.Running,
+			PendingPrompt: t.PendingPrompt,
+		})
+	}
+	return out
+}
+
+func newBridgeTestEnv(tabs []TabMeta) *bridgeTestEnv {
+	return newBridgeTestEnvSessions(tabsToSessions(tabs))
+}
+
+func newBridgeTestEnvSessions(sessions []bot.DesktopSessionInfo) *bridgeTestEnv {
+	env := &bridgeTestEnv{
+		notified:  make(chan bridgeNotifyCall, 16),
+		approves:  make(chan [2]string, 16),
+		answers:   make(chan [2]string, 16),
+		driven:    make(chan [2]string, 16),
+		announced: make(chan [2]string, 16),
+		persisted: make(chan []bot.DesktopWatchRoute, 16),
+	}
+	env.hub = newBotBridgeHub(botBridgeDeps{
+		sessions: func() []bot.DesktopSessionInfo { return sessions },
+		approveTab: func(tabID, id string, allow, session, persist bool) {
+			env.approves <- [2]string{tabID, fmt.Sprintf("%s:%t", id, allow)}
+		},
+		answerTab: func(tabID, id string, answers []QuestionAnswer) {
+			env.answers <- [2]string{tabID, id}
+		},
+		notify: func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
+			env.notified <- bridgeNotifyCall{connectionID: connectionID, domain: domain, msg: msg}
+			return bot.SendResult{MessageID: "sent-1"}, nil
+		},
+		drive: func(tabID, text string, route bot.DesktopWatchRoute) error {
+			if env.driveErr != nil {
+				return env.driveErr
+			}
+			env.driven <- [2]string{tabID, text}
+			return nil
+		},
+		announce: func(tabID, text string) {
+			env.announced <- [2]string{tabID, text}
+		},
+		persistWatchers: func(routes []bot.DesktopWatchRoute) error {
+			env.persisted <- routes
+			return env.persistErr
+		},
+		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+	})
+	return env
+}
+
+func (env *bridgeTestEnv) waitNotification(t *testing.T) bridgeNotifyCall {
+	t.Helper()
+	select {
+	case call := <-env.notified:
+		return call
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for bridge notification")
+		return bridgeNotifyCall{}
+	}
+}
+
+func (env *bridgeTestEnv) expectNoNotification(t *testing.T) {
+	t.Helper()
+	select {
+	case call := <-env.notified:
+		t.Fatalf("unexpected notification: %+v", call)
+	case <-time.After(100 * time.Millisecond):
+	}
+}
+
+func testWatchRoute() bot.DesktopWatchRoute {
+	return bot.DesktopWatchRoute{
+		ConnectionID: "feishu-main",
+		Domain:       "feishu",
+		Platform:     bot.PlatformFeishu,
+		ChatType:     bot.ChatDM,
+		ChatID:       "chat-god",
+	}
+}
+
+func testGroupRoute() bot.DesktopWatchRoute {
+	r := testWatchRoute()
+	r.ChatType = bot.ChatGroup
+	r.ChatID = "group-god"
+	return r
+}
+
+func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
+	if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
+		t.Fatal("takeover from a group chat must be rejected (non-admin members could otherwise drive it)")
+	}
+	if _, err := env.hub.Takeover(testWatchRoute(), "tab-1"); err != nil {
+		t.Fatalf("DM takeover should work: %v", err)
+	}
+}
+
+func TestBridgeTakeoverSwitchAnnouncesReleaseToOldTab(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-a", Label: "A", Ready: true},
+		{TabID: "tab-b", Label: "B", Ready: true},
+	})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-a"); err != nil {
+		t.Fatalf("takeover A: %v", err)
+	}
+	if got := <-env.announced; got[0] != "tab-a" {
+		t.Fatalf("first announce = %v, want tab-a takeover", got)
+	}
+	if _, err := env.hub.Takeover(route, "tab-b"); err != nil {
+		t.Fatalf("switch to B: %v", err)
+	}
+	seen := map[string]bool{}
+	for i := 0; i < 2; i++ {
+		select {
+		case got := <-env.announced:
+			seen[got[0]] = true
+		case <-time.After(time.Second):
+			t.Fatalf("missing announce after switch; seen=%v", seen)
+		}
+	}
+	if !seen["tab-a"] || !seen["tab-b"] {
+		t.Fatalf("switch should announce release to tab-a and takeover to tab-b; seen=%v", seen)
+	}
+}
+
+func TestBridgeDriveInputBusyReturnsBusyMessage(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	<-env.announced
+	env.driveErr = errDriveBusy
+	_, err := env.hub.DriveInput(route, "hi")
+	if err == nil || !strings.Contains(err.Error(), "正在执行中") {
+		t.Fatalf("busy drive should surface a clean busy message, got %v", err)
+	}
+}
+
+func TestBridgeApprovalRedactsSubjectInGroup(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testGroupRoute(), true)
+	<-env.persisted
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf /secret"}})
+	call := env.waitNotification(t)
+	if strings.Contains(call.msg.Text, "rm -rf /secret") {
+		t.Fatalf("group notification leaked the command line: %q", call.msg.Text)
+	}
+	if call.msg.Card != nil {
+		for _, el := range call.msg.Card.Elements {
+			if strings.Contains(el.Content, "rm -rf /secret") {
+				t.Fatal("group card leaked the command line")
+			}
+		}
+	}
+}
+
+func TestBridgeApprovalShowsSubjectInDM(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+	<-env.persisted
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf build"}})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "rm -rf build") {
+		t.Fatalf("DM notification should show the command line: %q", call.msg.Text)
+	}
+}
+
+func TestBridgeAskRedactsPromptAndOptionsInGroup(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	if err := env.hub.SetWatch(testGroupRoute(), true); err != nil {
+		t.Fatalf("SetWatch: %v", err)
+	}
+	<-env.persisted
+	env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{
+		ID: "redacted-ask",
+		Questions: []event.AskQuestion{{
+			ID: "q1", Prompt: "INTERNAL_ONLY_PROMPT", Options: []event.AskOption{{Label: "CHOICE_INTERNAL"}},
+		}},
+	}})
+	call := env.waitNotification(t)
+	if strings.Contains(call.msg.Text, "INTERNAL_ONLY_PROMPT") || strings.Contains(call.msg.Text, "CHOICE_INTERNAL") {
+		t.Fatalf("group notification leaked ask details: %q", call.msg.Text)
+	}
+	if call.msg.Card != nil {
+		t.Fatal("group ask notification must not include option buttons")
+	}
+}
+
+func TestBridgeSessionsIncludePendingIDs(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash"}})
+	env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: "q1", Questions: []event.AskQuestion{{ID: "x", Prompt: "?"}}}})
+	found := map[string]bool{}
+	for _, s := range env.hub.Sessions() {
+		if s.TabID != "tab-1" {
+			continue
+		}
+		for _, p := range s.Pending {
+			found[p.ID] = true
+		}
+	}
+	if !found["a1"] || !found["q1"] {
+		t.Fatalf("Sessions() should surface pending approval and ask ids; got %v", found)
+	}
+}
+
+func TestBridgePersistDropsStaleSnapshot(t *testing.T) {
+	env := newBridgeTestEnvSessions(nil)
+	// Two subscribes: the second (newer seq) must be the persisted result even
+	// though we invoke the seed-restore afterward.
+	env.hub.SetWatch(testWatchRoute(), true)
+	<-env.persisted
+	env.hub.SetWatch(testGroupRoute(), true)
+	routes := <-env.persisted
+	if len(routes) != 2 {
+		t.Fatalf("persisted routes = %d, want both subscriptions", len(routes))
+	}
+}
+
+func TestBridgeApprovalNotifiesWatchersAndRoutesApproval(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "修复登录"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
+		ID: "appr-1", Tool: "bash", Subject: "rm -rf build",
+	}})
+
+	call := env.waitNotification(t)
+	if call.connectionID != "feishu-main" || call.msg.ChatID != "chat-god" {
+		t.Fatalf("notification routed to %s/%s, want feishu-main/chat-god", call.connectionID, call.msg.ChatID)
+	}
+	for _, want := range []string{"修复登录", "bash", "rm -rf build", "/desktop approve appr-1"} {
+		if !strings.Contains(call.msg.Text, want) {
+			t.Fatalf("notification text = %q, want it to contain %q", call.msg.Text, want)
+		}
+	}
+	if call.msg.Card == nil {
+		t.Fatal("approval notification should carry an interactive card")
+	}
+
+	feedback, err := env.hub.Approve("appr-1", true)
+	if err != nil {
+		t.Fatalf("Approve: %v", err)
+	}
+	if !strings.Contains(feedback, "先到者为准") {
+		t.Fatalf("feedback = %q, want first-wins note", feedback)
+	}
+	select {
+	case got := <-env.approves:
+		if got[0] != "tab-1" || got[1] != "appr-1:true" {
+			t.Fatalf("approve routed as %v, want tab-1/appr-1:true", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("approve was not routed to the tab")
+	}
+
+	// 同一 ID 第二次应答:pending 已清,返回未找到。
+	if _, err := env.hub.Approve("appr-1", false); err == nil {
+		t.Fatal("second Approve on the same id should fail")
+	}
+}
+
+func TestBridgePendingRecordedWithoutWatchers(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
+
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-2", Tool: "bash"}})
+	env.expectNoNotification(t)
+
+	if _, err := env.hub.Approve("appr-2", false); err != nil {
+		t.Fatalf("Approve without watchers should still work: %v", err)
+	}
+	select {
+	case got := <-env.approves:
+		if got[1] != "appr-2:false" {
+			t.Fatalf("deny routed as %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("deny was not routed")
+	}
+}
+
+func TestBridgeTurnDoneClearsPendingAndNotifies(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-3", Tool: "bash"}})
+	env.waitNotification(t)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "✅") || !strings.Contains(call.msg.Text, "会话一") {
+		t.Fatalf("turn-done text = %q", call.msg.Text)
+	}
+
+	if _, err := env.hub.Approve("appr-3", true); err == nil {
+		t.Fatal("pending approval should be cleared by TurnDone")
+	}
+}
+
+func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("context canceled")})
+	env.expectNoNotification(t)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("boom")})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "boom") {
+		t.Fatalf("error text = %q", call.msg.Text)
+	}
+}
+
+func TestBridgeAskAnswerRoundTrip(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-2", event.Event{Kind: event.AskRequest, Ask: event.Ask{
+		ID: "ask-1",
+		Questions: []event.AskQuestion{{
+			ID:      "q1",
+			Prompt:  "选一个方案",
+			Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
+		}},
+	}})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "/desktop answer ask-1") {
+		t.Fatalf("ask notification = %q, want answer hint", call.msg.Text)
+	}
+	if call.msg.Card == nil {
+		t.Fatal("single-choice ask should carry option buttons")
+	}
+
+	questions, ok := env.hub.AskQuestions("ask-1")
+	if !ok || len(questions) != 1 {
+		t.Fatalf("AskQuestions = %v/%v", questions, ok)
+	}
+	if _, err := env.hub.Answer("ask-1", []event.AskAnswer{{QuestionID: "q1", Selected: []string{"B"}}}); err != nil {
+		t.Fatalf("Answer: %v", err)
+	}
+	select {
+	case got := <-env.answers:
+		if got[0] != "tab-2" || got[1] != "ask-1" {
+			t.Fatalf("answer routed as %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("answer was not routed")
+	}
+}
+
+func TestBridgeWatchLifecycleStopsNotifications(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+
+	env.hub.SetWatch(route, true)
+	if !env.hub.Watching(route) {
+		t.Fatal("route should be watching after SetWatch(true)")
+	}
+	env.hub.SetWatch(route, false)
+	if env.hub.Watching(route) {
+		t.Fatal("route should not be watching after SetWatch(false)")
+	}
+
+	env.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
+	env.expectNoNotification(t)
+}
+
+func TestBridgeSetWatchPersistsAndSeedRestores(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+
+	env.hub.SetWatch(route, true)
+	select {
+	case routes := <-env.persisted:
+		if len(routes) != 1 || routes[0].Key() != route.Key() {
+			t.Fatalf("persisted = %+v, want the subscribed route", routes)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("SetWatch did not persist watchers")
+	}
+
+	// 模拟重启:全新 hub 从配置种子恢复。
+	env2 := newBridgeTestEnv(nil)
+	env2.hub.seedWatchers([]bot.DesktopWatchRoute{route}, env2.hub.watcherVersion())
+	if !env2.hub.Watching(route) {
+		t.Fatal("seeded hub should be watching the persisted route")
+	}
+	env2.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
+	if call := env2.waitNotification(t); !strings.Contains(call.msg.Text, "✅") {
+		t.Fatalf("seeded watcher did not receive notifications: %q", call.msg.Text)
+	}
+}
+
+func TestBridgeSeedDoesNotOverwriteNewerRuntimeWatch(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+	staleVersion := env.hub.watcherVersion()
+	if err := env.hub.SetWatch(route, true); err != nil {
+		t.Fatalf("SetWatch: %v", err)
+	}
+	<-env.persisted
+
+	// Simulate a runtime refresh carrying a config snapshot loaded before the
+	// watch command persisted. It must not erase the newer in-process route.
+	env.hub.seedWatchers(nil, staleVersion)
+	if !env.hub.Watching(route) {
+		t.Fatal("stale config seed overwrote the newer runtime subscription")
+	}
+}
+
+func TestBridgeSeedPreservesWatchAfterPersistFailure(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	env.persistErr = errors.New("disk unavailable")
+	route := testWatchRoute()
+	if err := env.hub.SetWatch(route, true); err == nil {
+		t.Fatal("SetWatch should report the persistence failure")
+	}
+	<-env.persisted
+
+	env.hub.seedWatchers(nil, env.hub.watcherVersion())
+	if !env.hub.Watching(route) {
+		t.Fatal("disk snapshot erased a runtime watch whose persistence failed")
+	}
+}
+
+func TestBridgeSeedAppliesFreshExternalConfig(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+	version := env.hub.watcherVersion()
+	env.hub.seedWatchers([]bot.DesktopWatchRoute{route}, version)
+	if !env.hub.Watching(route) {
+		t.Fatal("initial config seed did not apply")
+	}
+
+	env.hub.seedWatchers(nil, version)
+	if env.hub.Watching(route) {
+		t.Fatal("fresh external config update did not replace the watcher set")
+	}
+}
+
+func TestBridgeApprovalRoutesToDetachedSession(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-bg", Label: "后台任务", Detached: true, Ready: true},
+	})
+
+	env.hub.observe("tab-bg", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-bg", Tool: "bash"}})
+	if _, err := env.hub.Approve("appr-bg", true); err != nil {
+		t.Fatalf("Approve on detached session: %v", err)
+	}
+	select {
+	case got := <-env.approves:
+		if got[0] != "tab-bg" {
+			t.Fatalf("approve routed to %v, want tab-bg", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("detached approval was not routed")
+	}
+}
+
+func TestBridgeTakeoverLifecycle(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-1", Label: "会话一", Ready: true},
+		{TabID: "tab-bg", Label: "后台", Detached: true},
+	})
+	route := testWatchRoute()
+
+	// 后台会话拒绝接管。
+	if _, err := env.hub.Takeover(route, "tab-bg"); err == nil {
+		t.Fatal("takeover of a detached session should fail")
+	}
+
+	feedback, err := env.hub.Takeover(route, "tab-1")
+	if err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	if !strings.Contains(feedback, "已接管") {
+		t.Fatalf("feedback = %q", feedback)
+	}
+	if env.hub.TakeoverTab(route) != "tab-1" {
+		t.Fatalf("TakeoverTab = %q, want tab-1", env.hub.TakeoverTab(route))
+	}
+	select {
+	case got := <-env.announced:
+		if got[0] != "tab-1" || !strings.Contains(got[1], "接管") {
+			t.Fatalf("announce = %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("takeover was not announced to the desktop transcript")
+	}
+
+	// 驱动输入路由到 tab。
+	if _, err := env.hub.DriveInput(route, "跑一下测试"); err != nil {
+		t.Fatalf("DriveInput: %v", err)
+	}
+	select {
+	case got := <-env.driven:
+		if got[0] != "tab-1" || got[1] != "跑一下测试" {
+			t.Fatalf("driven = %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("drive input was not routed")
+	}
+
+	// 另一个聊天抢同一会话被拒。
+	other := route
+	other.ChatID = "chat-other"
+	if _, err := env.hub.Takeover(other, "tab-1"); err == nil {
+		t.Fatal("takeover by another chat should be rejected while held")
+	}
+
+	// 释放。
+	if _, err := env.hub.Release(route); err != nil {
+		t.Fatalf("Release: %v", err)
+	}
+	if env.hub.TakeoverTab(route) != "" {
+		t.Fatal("binding should be cleared after release")
+	}
+	if _, err := env.hub.Release(route); err == nil {
+		t.Fatal("second release should report no binding")
+	}
+}
+
+func TestBridgeDriveInputRejectsRunningSession(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-1", Label: "会话一", Ready: true, Running: true},
+	})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	<-env.announced
+	if _, err := env.hub.DriveInput(route, "hello"); err == nil || !strings.Contains(err.Error(), "正在执行中") {
+		t.Fatalf("DriveInput on running session = %v, want busy rejection", err)
+	}
+}
+
+func TestBridgeReclaimFromDesktopNotifiesController(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-1", Label: "会话一", Ready: true},
+	})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	<-env.announced
+
+	env.hub.reclaimFromDesktop("tab-1")
+	if env.hub.TakeoverTab(route) != "" {
+		t.Fatal("reclaim should clear the binding")
+	}
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "收回") || call.msg.ChatID != route.ChatID {
+		t.Fatalf("reclaim notification = %+v", call)
+	}
+
+	// 未接管 tab 的 reclaim 是 no-op。
+	env.hub.reclaimFromDesktop("tab-1")
+	env.expectNoNotification(t)
+}
diff --git a/desktop/bot_connection_app.go b/desktop/bot_connection_app.go
new file mode 100644
index 0000000..c664b6c
--- /dev/null
+++ b/desktop/bot_connection_app.go
@@ -0,0 +1,982 @@
+package main
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/bot/feishu"
+	"reasonix/internal/bot/weixin"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+)
+
+type BotConnectionCredentialView struct {
+	AppID        string `json:"appId"`
+	AppSecretEnv string `json:"appSecretEnv"`
+	AccountID    string `json:"accountId"`
+	TokenEnv     string `json:"tokenEnv"`
+	SecretSet    bool   `json:"secretSet"`
+}
+
+type BotConnectionSessionMappingView struct {
+	RemoteID      string `json:"remoteId"`
+	SessionID     string `json:"sessionId"`
+	SessionSource string `json:"sessionSource"`
+	ChatType      string `json:"chatType"`
+	UserID        string `json:"userId"`
+	ThreadID      string `json:"threadId"`
+	Scope         string `json:"scope"`
+	WorkspaceRoot string `json:"workspaceRoot"`
+	UpdatedAt     string `json:"updatedAt"`
+}
+
+type BotConnectionView struct {
+	ID               string                            `json:"id"`
+	Provider         string                            `json:"provider"`
+	Domain           string                            `json:"domain"`
+	Label            string                            `json:"label"`
+	Enabled          bool                              `json:"enabled"`
+	Status           string                            `json:"status"`
+	Model            string                            `json:"model"`
+	ToolApprovalMode string                            `json:"toolApprovalMode"`
+	WorkspaceRoot    string                            `json:"workspaceRoot"`
+	Access           BotAccessView                     `json:"access"`
+	Credential       BotConnectionCredentialView       `json:"credential"`
+	SessionMappings  []BotConnectionSessionMappingView `json:"sessionMappings"`
+	LastError        string                            `json:"lastError"`
+	CreatedAt        string                            `json:"createdAt"`
+	UpdatedAt        string                            `json:"updatedAt"`
+}
+
+type BotInstallStartResult struct {
+	OK         bool   `json:"ok"`
+	Provider   string `json:"provider"`
+	Domain     string `json:"domain"`
+	InstallID  string `json:"installId"`
+	URL        string `json:"url"`
+	DeviceCode string `json:"deviceCode"`
+	UserCode   string `json:"userCode"`
+	Interval   int    `json:"interval"`
+	ExpireIn   int    `json:"expireIn"`
+	Message    string `json:"message"`
+}
+
+type BotInstallPollResult struct {
+	Done       bool              `json:"done"`
+	Connection BotConnectionView `json:"connection"`
+	Status     string            `json:"status"`
+	Message    string            `json:"message"`
+	Error      string            `json:"error"`
+}
+
+type BotConnectionDiagnostic struct {
+	ID           string `json:"id"`
+	Label        string `json:"label"`
+	Status       string `json:"status"`
+	Message      string `json:"message"`
+	MessageID    string `json:"messageId"`
+	Phase        string `json:"phase"`
+	Code         string `json:"code"`
+	ReportKind   string `json:"reportKind"`
+	ReportDetail string `json:"reportDetail"`
+	OccurredAt   string `json:"occurredAt"`
+}
+
+type botInstallSession struct {
+	Provider   string
+	Domain     string
+	PollDomain string
+	DeviceCode string
+	UserCode   string
+	StartedAt  time.Time
+	ExpireAt   time.Time
+	Weixin     *weixin.LoginSession
+}
+
+func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) {
+	provider, domain = normalizeBotInstallTarget(provider, domain)
+	if provider == "weixin" {
+		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+		defer cancel()
+		session, err := weixin.StartLogin(ctx)
+		if err != nil {
+			return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil
+		}
+		installID := randomInstallID()
+		a.mu.Lock()
+		if a.botInstalls == nil {
+			a.botInstalls = map[string]*botInstallSession{}
+		}
+		a.botInstalls[installID] = &botInstallSession{
+			Provider:   provider,
+			Domain:     domain,
+			DeviceCode: session.QRCode,
+			StartedAt:  session.StartedAt,
+			ExpireAt:   time.Now().Add(2 * time.Minute),
+			Weixin:     session,
+		}
+		a.mu.Unlock()
+		return BotInstallStartResult{
+			OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode),
+			DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。",
+		}, nil
+	}
+	if provider != "feishu" {
+		return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil
+	}
+	return a.startFeishuConnectionInstall(domain)
+}
+
+func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
+	installID = strings.TrimSpace(installID)
+	// Copy the session under a.mu: overlapping polls of the same install can
+	// race the locked PollDomain upgrade below with unlocked field reads.
+	a.mu.RLock()
+	sessionPtr := a.botInstalls[installID]
+	var sessionCopy botInstallSession
+	if sessionPtr != nil {
+		sessionCopy = *sessionPtr
+	}
+	a.mu.RUnlock()
+	if sessionPtr == nil {
+		return BotInstallPollResult{Error: "install session not found"}, nil
+	}
+	session := &sessionCopy
+	if time.Now().After(session.ExpireAt) {
+		a.deleteBotInstall(installID)
+		return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
+	}
+	if session.Provider == "weixin" {
+		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+		defer cancel()
+		result, status, err := weixin.PollLogin(ctx, session.Weixin)
+		if err != nil {
+			return BotInstallPollResult{Status: status, Error: err.Error()}, nil
+		}
+		if result == nil {
+			return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil
+		}
+		a.deleteBotInstall(installID)
+		conn, err := a.upsertBotConnection(config.BotConnectionConfig{
+			ID:         connectionID("weixin", "weixin"),
+			Provider:   "weixin",
+			Domain:     "weixin",
+			Label:      "微信",
+			Enabled:    true,
+			Status:     "connected",
+			Access:     botInstallAccess(result.UserID),
+			Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
+		}, func(c *config.Config) {
+			c.Bot.Enabled = true
+			c.Bot.Weixin.Enabled = true
+			c.Bot.Weixin.AccountID = result.AccountID
+			c.Bot.Weixin.APIBase = result.BaseURL
+			if c.Bot.Weixin.TokenEnv == "" {
+				c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
+			}
+			c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID)
+		})
+		if err != nil {
+			return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+		}
+		a.refreshBotRuntimeAsync()
+		return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil
+	}
+	return a.pollFeishuConnectionInstall(installID, session)
+}
+
+func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) {
+	cfg, err := a.loadDesktopBotConfig()
+	if err != nil {
+		return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
+	}
+	for _, conn := range cfg.Bot.Connections {
+		if conn.ID == id {
+			status := "ok"
+			message := "连接配置已保存。"
+			phase := "config"
+			code := "config_ok"
+			reportable := false
+			if !conn.Enabled {
+				status = "disabled"
+				message = "连接已保存但未启用。"
+				code = "connection_disabled"
+			} else if conn.Status != "connected" {
+				status = firstNonEmptyBot(conn.Status, "pending")
+				message = firstNonEmptyBot(conn.LastError, "连接还未完成。")
+				phase = "install"
+				code = "connection_not_connected"
+				reportable = status == "error" || strings.TrimSpace(conn.LastError) != ""
+			} else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) {
+				status = "warning"
+				message = conn.Credential.AppSecretEnv + " 未设置。"
+				phase = "credential"
+				code = "secret_missing"
+				reportable = true
+			} else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) {
+				status = "warning"
+				message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。"
+				phase = "credential"
+				code = "secret_missing"
+				reportable = true
+			} else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) {
+				status = "warning"
+				message = "未找到已保存的微信登录凭据。"
+				phase = "credential"
+				code = "secret_missing"
+				reportable = true
+			}
+			return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil
+		}
+	}
+	return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
+}
+
+func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) {
+	cfg, err := a.loadDesktopBotConfig()
+	if err != nil {
+		return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
+	}
+	var conn *config.BotConnectionConfig
+	for i := range cfg.Bot.Connections {
+		if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) {
+			conn = &cfg.Bot.Connections[i]
+			break
+		}
+	}
+	if conn == nil {
+		return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
+	}
+	target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings))
+	if conn.Provider != "feishu" && conn.Provider != "weixin" {
+		return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil
+	}
+	if target == "" {
+		return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	var result bot.SendResult
+	switch conn.Provider {
+	case "feishu":
+		feishuCfg := cfg.Bot.Feishu
+		feishuCfg.Enabled = true
+		feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain)
+		feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID)
+		feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv)
+		result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
+	case "weixin":
+		weixinCfg := cfg.Bot.Weixin
+		weixinCfg.Enabled = true
+		weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID)
+		weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv)
+		result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
+	}
+	if err != nil {
+		return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil
+	}
+	_ = a.rememberBotConnectionRemote(conn.ID, target)
+	msg := "测试消息已发送。"
+	if result.MessageID != "" {
+		msg += " Message ID: " + result.MessageID
+	}
+	diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false)
+	diag.MessageID = result.MessageID
+	return diag, nil
+}
+
+func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
+	id = strings.TrimSpace(id)
+	label := ""
+	if conn != nil {
+		id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id)
+		label = strings.TrimSpace(conn.Label)
+	}
+	occurredAt := time.Now().UTC().Format(time.RFC3339)
+	diag := BotConnectionDiagnostic{
+		ID:         id,
+		Label:      label,
+		Status:     strings.TrimSpace(status),
+		Message:    strings.TrimSpace(message),
+		Phase:      strings.TrimSpace(phase),
+		Code:       strings.TrimSpace(code),
+		OccurredAt: occurredAt,
+	}
+	if reportable {
+		diag.ReportKind = "bot"
+		diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt)
+		if diag.ReportDetail == "" {
+			diag.ReportKind = ""
+		}
+	}
+	return diag
+}
+
+func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string {
+	provider := "unknown"
+	domain := "unknown"
+	configuredStatus := ""
+	enabled := false
+	workspaceScope := "global"
+	sessionMappings := 0
+	appIDSet := false
+	appSecretEnvConfigured := false
+	tokenEnvConfigured := false
+	secretAvailable := false
+	if conn != nil {
+		provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider)
+		domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain)
+		configuredStatus = strings.TrimSpace(conn.Status)
+		enabled = conn.Enabled
+		if strings.TrimSpace(conn.WorkspaceRoot) != "" {
+			workspaceScope = "project"
+		}
+		sessionMappings = len(conn.SessionMappings)
+		appIDSet = strings.TrimSpace(conn.Credential.AppID) != ""
+		appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != ""
+		tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != ""
+		secretAvailable = botCredentialSecretSet(*conn)
+	}
+	summary := botConnectionReportSummary(code, message)
+	lines := []string{
+		"Bot connection diagnostic",
+		"",
+		"connection_id: " + safeBotReportValue(fallbackID),
+		"provider: " + safeBotReportValue(provider),
+		"domain: " + safeBotReportValue(domain),
+		"status: " + safeBotReportValue(status),
+		"phase: " + safeBotReportValue(phase),
+		"code: " + safeBotReportValue(code),
+		fmt.Sprintf("enabled: %t", enabled),
+		"configured_status: " + safeBotReportValue(configuredStatus),
+		fmt.Sprintf("app_id_set: %t", appIDSet),
+		fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured),
+		fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured),
+		fmt.Sprintf("secret_available: %t", secretAvailable),
+		"workspace_scope: " + workspaceScope,
+		fmt.Sprintf("session_mappings: %d", sessionMappings),
+		"",
+		"summary: " + summary,
+	}
+	payload := frontendCrashPayload{
+		SchemaVersion: 2,
+		Kind:          "bot",
+		Source:        "bot.runtime",
+		Label:         botConnectionReportLabel(provider, domain, phase),
+		Message:       strings.Join(lines, "\n"),
+		ErrorType:     "BotConnectionDiagnostic",
+		ErrorMessage:  summary,
+		TopFrame:      "bot." + safeBotReportSegment(phase),
+		OccurredAt:    occurredAt,
+	}
+	detail, err := json.Marshal(payload)
+	if err != nil {
+		return ""
+	}
+	return string(detail)
+}
+
+func botConnectionReportSummary(code, message string) string {
+	switch strings.TrimSpace(code) {
+	case "config_load_failed":
+		return "desktop bot config could not be loaded: " + scrubSensitiveText(message)
+	case "connection_missing":
+		return "bot connection record was not found"
+	case "connection_not_connected":
+		return "bot connection is not connected: " + scrubSensitiveText(message)
+	case "secret_missing":
+		return "required bot credential is not available"
+	case "test_send_failed":
+		return "bot test message failed: " + scrubSensitiveText(message)
+	default:
+		if strings.TrimSpace(message) == "" {
+			return strings.TrimSpace(code)
+		}
+		return scrubSensitiveText(message)
+	}
+}
+
+func botConnectionReportLabel(provider, domain, phase string) string {
+	parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)}
+	return strings.Trim(strings.Join(parts, "."), ".")
+}
+
+func safeBotReportSegment(s string) string {
+	s = strings.ToLower(strings.TrimSpace(s))
+	if s == "" {
+		return "unknown"
+	}
+	var b strings.Builder
+	for _, r := range s {
+		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+			b.WriteRune(r)
+			continue
+		}
+		if b.Len() == 0 || strings.HasSuffix(b.String(), ".") {
+			continue
+		}
+		b.WriteByte('.')
+	}
+	out := strings.Trim(b.String(), ".")
+	if out == "" {
+		return "unknown"
+	}
+	return out
+}
+
+func safeBotReportValue(s string) string {
+	s = safeBotReportSegment(s)
+	if len(s) > 80 {
+		return s[:80]
+	}
+	return s
+}
+
+func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) {
+	// The official registration SDK always begins on the Feishu accounts domain.
+	// Lark tenants are detected from the first poll response, then polling moves
+	// to the Lark accounts domain for the final credential exchange.
+	beginDomain := "feishu"
+	data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{
+		"action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id",
+	})
+	if err != nil {
+		return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
+	}
+	deviceCode := stringValue(data["device_code"])
+	verifyURL := stringValue(data["verification_uri_complete"])
+	userCode := stringValue(data["user_code"])
+	if deviceCode == "" || verifyURL == "" {
+		return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil
+	}
+	qrURL, err := feishuRegistrationQRCodeURL(verifyURL)
+	if err != nil {
+		return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
+	}
+	installID := randomInstallID()
+	interval := intValue(data["interval"], 5)
+	expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300)
+	a.mu.Lock()
+	if a.botInstalls == nil {
+		a.botInstalls = map[string]*botInstallSession{}
+	}
+	a.botInstalls[installID] = &botInstallSession{
+		Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode,
+		StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second),
+	}
+	a.mu.Unlock()
+	return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil
+}
+
+func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) {
+	pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu")
+	data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode})
+	if err != nil {
+		return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+	}
+	if errText := stringValue(data["error"]); errText != "" {
+		if errText == "authorization_pending" || errText == "slow_down" {
+			return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil
+		}
+		a.deleteBotInstall(installID)
+		return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil
+	}
+	if statusCode >= 400 {
+		a.deleteBotInstall(installID)
+		return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil
+	}
+	if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" {
+		a.mu.Lock()
+		if current := a.botInstalls[installID]; current != nil {
+			current.PollDomain = "lark"
+		}
+		a.mu.Unlock()
+		return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil
+	}
+	appID := stringValue(data["client_id"])
+	appSecret := stringValue(data["client_secret"])
+	if appID == "" || appSecret == "" {
+		return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil
+	}
+	a.deleteBotInstall(installID)
+	domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data)
+	userID := feishuInstallUserID(data)
+	secretEnv := "FEISHU_BOT_APP_SECRET"
+	if domain == "lark" {
+		secretEnv = "LARK_BOT_APP_SECRET"
+	}
+	if err := upsertDotEnv(secretEnv, appSecret); err != nil {
+		return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+	}
+	label := "飞书"
+	if domain == "lark" {
+		label = "Lark"
+	}
+	conn, err := a.upsertBotConnection(config.BotConnectionConfig{
+		ID:         connectionID("feishu", domain),
+		Provider:   "feishu",
+		Domain:     domain,
+		Label:      label,
+		Enabled:    true,
+		Status:     "connected",
+		Access:     botInstallAccess(userID),
+		Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
+	}, func(c *config.Config) {
+		c.Bot.Enabled = true
+		c.Bot.Feishu.Enabled = true
+		c.Bot.Feishu.Domain = domain
+		c.Bot.Feishu.AppID = appID
+		c.Bot.Feishu.AppSecretEnv = secretEnv
+		c.Bot.Feishu.Mode = "websocket"
+		c.Bot.Feishu.RequireMention = true
+		c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID)
+	})
+	if err != nil {
+		return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+	}
+	a.refreshBotRuntimeAsync()
+	return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil
+}
+
+func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) {
+	now := time.Now().UTC().Format(time.RFC3339)
+	if conn.CreatedAt == "" {
+		conn.CreatedAt = now
+	}
+	conn.UpdatedAt = now
+	if conn.Status == "" {
+		conn.Status = "connected"
+	}
+	if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
+		conn.ToolApprovalMode = "ask"
+	}
+	if conn.ID == "" {
+		conn.ID = connectionID(conn.Provider, conn.Domain)
+	}
+	err := a.applyConfigOnly(func(c *config.Config) error {
+		if updateLegacy != nil {
+			updateLegacy(c)
+		}
+		replaced := false
+		for i, existing := range c.Bot.Connections {
+			if existing.ID == conn.ID {
+				conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
+				if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
+					conn.Access = existing.Access
+				}
+				c.Bot.Connections[i] = conn
+				replaced = true
+				break
+			}
+		}
+		if !replaced {
+			c.Bot.Connections = append(c.Bot.Connections, conn)
+		}
+		return nil
+	})
+	return botConnectionView(conn), err
+}
+
+func (a *App) rememberBotConnectionRemote(id, remoteID string) error {
+	id = strings.TrimSpace(id)
+	remoteID = strings.TrimSpace(remoteID)
+	if id == "" || remoteID == "" {
+		return nil
+	}
+	now := time.Now().UTC().Format(time.RFC3339)
+	return a.applyConfigOnly(func(c *config.Config) error {
+		for i := range c.Bot.Connections {
+			if c.Bot.Connections[i].ID != id {
+				continue
+			}
+			for j := range c.Bot.Connections[i].SessionMappings {
+				if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID {
+					workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot)
+					scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot)
+					c.Bot.Connections[i].SessionMappings[j].Scope = scope
+					c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot)
+					c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now
+					c.Bot.Connections[i].UpdatedAt = now
+					return nil
+				}
+			}
+			scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot)
+			c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{
+				RemoteID:      remoteID,
+				SessionID:     "",
+				Scope:         scope,
+				WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot),
+				UpdatedAt:     now,
+			})
+			c.Bot.Connections[i].UpdatedAt = now
+			return nil
+		}
+		return nil
+	})
+}
+
+func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string {
+	for _, mapping := range mappings {
+		if strings.TrimSpace(mapping.RemoteID) != "" {
+			return strings.TrimSpace(mapping.RemoteID)
+		}
+	}
+	return ""
+}
+
+func (a *App) deleteBotInstall(installID string) {
+	a.mu.Lock()
+	delete(a.botInstalls, installID)
+	a.mu.Unlock()
+}
+
+func normalizeBotInstallTarget(provider, domain string) (string, string) {
+	provider = strings.ToLower(strings.TrimSpace(provider))
+	domain = strings.ToLower(strings.TrimSpace(domain))
+	if provider == "lark" {
+		provider = "feishu"
+		domain = "lark"
+	}
+	if provider == "weixin" || provider == "wechat" {
+		return "weixin", "weixin"
+	}
+	if domain != "lark" {
+		domain = "feishu"
+	}
+	return "feishu", domain
+}
+
+func feishuAccountsBase(domain string) string {
+	if domain == "lark" {
+		return "https://accounts.larksuite.com"
+	}
+	return "https://accounts.feishu.cn"
+}
+
+func feishuRegistrationQRCodeURL(rawURL string) (string, error) {
+	parsedURL, err := url.Parse(rawURL)
+	if err != nil {
+		return "", err
+	}
+	query := parsedURL.Query()
+	query.Set("from", "sdk")
+	query.Set("tp", "sdk")
+	query.Set("source", "go-sdk")
+	parsedURL.RawQuery = query.Encode()
+	return parsedURL.String(), nil
+}
+
+func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) {
+	data, status, err := postFeishuInstallFormResult(base, body)
+	if err != nil {
+		return nil, err
+	}
+	if status >= 400 {
+		return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"])))
+	}
+	return data, nil
+}
+
+func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	reqBody := url.Values{}
+	for k, v := range body {
+		reqBody.Set(k, v)
+	}
+	req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
+	if err != nil {
+		return nil, 0, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer resp.Body.Close()
+	var out map[string]any
+	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+		return nil, resp.StatusCode, err
+	}
+	return out, resp.StatusCode, nil
+}
+
+func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
+	return BotConnectionView{
+		ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
+		Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
+		Access: botAccessViewFromConfig(conn.Access),
+		Credential: BotConnectionCredentialView{
+			AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
+			SecretSet: botCredentialSecretSet(conn),
+		},
+		SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot),
+		LastError:       conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt,
+	}
+}
+
+func botCredentialSecretSet(conn config.BotConnectionConfig) bool {
+	if conn.Credential.AppSecretEnv != "" {
+		return envIsSet(conn.Credential.AppSecretEnv)
+	}
+	if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) {
+		return true
+	}
+	if conn.Provider == "weixin" {
+		return weixin.HasSavedAccount(conn.Credential.AccountID)
+	}
+	return false
+}
+
+func feishuInstallDomain(fallback string, data map[string]any) string {
+	if userInfo, ok := data["user_info"].(map[string]any); ok {
+		if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") {
+			return "lark"
+		}
+		return "feishu"
+	}
+	if strings.EqualFold(fallback, "lark") {
+		return "lark"
+	}
+	return "feishu"
+}
+
+func feishuInstallUserID(data map[string]any) string {
+	if userInfo, ok := data["user_info"].(map[string]any); ok {
+		return firstNonEmptyBot(
+			stringValue(userInfo["open_id"]),
+			stringValue(userInfo["union_id"]),
+			stringValue(userInfo["user_id"]),
+		)
+	}
+	return ""
+}
+
+func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView {
+	if connections == nil {
+		return []BotConnectionView{}
+	}
+	out := make([]BotConnectionView, 0, len(connections))
+	for _, conn := range connections {
+		out = append(out, botConnectionView(conn))
+	}
+	return out
+}
+
+func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
+	return config.BotConnectionConfig{
+		ID:               strings.TrimSpace(view.ID),
+		Provider:         strings.TrimSpace(view.Provider),
+		Domain:           strings.TrimSpace(view.Domain),
+		Label:            strings.TrimSpace(view.Label),
+		Enabled:          view.Enabled,
+		Status:           strings.TrimSpace(view.Status),
+		Model:            strings.TrimSpace(view.Model),
+		ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
+		WorkspaceRoot:    strings.TrimSpace(view.WorkspaceRoot),
+		Access:           botAccessConfigFromView(view.Access),
+		Credential: config.BotConnectionCredential{
+			AppID:        strings.TrimSpace(view.Credential.AppID),
+			AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
+			AccountID:    strings.TrimSpace(view.Credential.AccountID),
+			TokenEnv:     strings.TrimSpace(view.Credential.TokenEnv),
+		},
+		SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot),
+		LastError:       strings.TrimSpace(view.LastError),
+		CreatedAt:       strings.TrimSpace(view.CreatedAt),
+		UpdatedAt:       strings.TrimSpace(view.UpdatedAt),
+	}
+}
+
+func normalizeBotConnectionToolApprovalMode(mode string) string {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "ask":
+		return "ask"
+	case "auto":
+		return "auto"
+	case "yolo", "full", "full-access", "bypass":
+		return "yolo"
+	default:
+		return ""
+	}
+}
+
+func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig {
+	if views == nil {
+		return nil
+	}
+	out := make([]config.BotConnectionConfig, 0, len(views))
+	for _, view := range views {
+		cfg := botConnectionConfig(view)
+		if cfg.ID == "" || cfg.Provider == "" {
+			continue
+		}
+		out = append(out, cfg)
+	}
+	return out
+}
+
+func botMappingScope(scope, workspaceRoot string) string {
+	if strings.TrimSpace(scope) == "project" {
+		return "project"
+	}
+	if strings.TrimSpace(workspaceRoot) != "" {
+		return "project"
+	}
+	return "global"
+}
+
+func botMappingWorkspaceRoot(scope, workspaceRoot string) string {
+	if botMappingScope(scope, workspaceRoot) != "project" {
+		return ""
+	}
+	return strings.TrimSpace(workspaceRoot)
+}
+
+func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView {
+	if mappings == nil {
+		return []BotConnectionSessionMappingView{}
+	}
+	out := make([]BotConnectionSessionMappingView, 0, len(mappings))
+	for _, m := range mappings {
+		workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
+		scope := botMappingScope(m.Scope, workspaceRoot)
+		out = append(out, BotConnectionSessionMappingView{
+			RemoteID:      m.RemoteID,
+			SessionID:     m.SessionID,
+			SessionSource: m.SessionSource,
+			ChatType:      m.ChatType,
+			UserID:        m.UserID,
+			ThreadID:      m.ThreadID,
+			Scope:         scope,
+			WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
+			UpdatedAt:     m.UpdatedAt,
+		})
+	}
+	return out
+}
+
+func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping {
+	if mappings == nil {
+		return nil
+	}
+	out := make([]config.BotConnectionSessionMapping, 0, len(mappings))
+	for _, m := range mappings {
+		workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
+		scope := botMappingScope(m.Scope, workspaceRoot)
+		out = append(out, config.BotConnectionSessionMapping{
+			RemoteID:      strings.TrimSpace(m.RemoteID),
+			SessionID:     strings.TrimSpace(m.SessionID),
+			SessionSource: strings.TrimSpace(m.SessionSource),
+			ChatType:      strings.TrimSpace(m.ChatType),
+			UserID:        strings.TrimSpace(m.UserID),
+			ThreadID:      strings.TrimSpace(m.ThreadID),
+			Scope:         scope,
+			WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
+			UpdatedAt:     strings.TrimSpace(m.UpdatedAt),
+		})
+	}
+	return out
+}
+
+func connectionID(provider, domain string) string {
+	return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
+}
+
+func botInstallAccess(userID string) config.BotAccessConfig {
+	userID = strings.TrimSpace(userID)
+	access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
+	if userID != "" {
+		access.Users = []string{userID}
+	}
+	return access
+}
+
+func randomInstallID() string {
+	var b [12]byte
+	if _, err := rand.Read(b[:]); err != nil {
+		return fmt.Sprintf("install-%d", time.Now().UnixNano())
+	}
+	return hex.EncodeToString(b[:])
+}
+
+func envIsSet(name string) bool {
+	return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != ""
+}
+
+func firstAny(values ...any) any {
+	for _, value := range values {
+		if value != nil {
+			return value
+		}
+	}
+	return nil
+}
+
+func firstNonEmptyBot(values ...string) string {
+	for _, value := range values {
+		if strings.TrimSpace(value) != "" {
+			return value
+		}
+	}
+	return ""
+}
+
+func appendUniqueBotString(values []string, next string) []string {
+	next = strings.TrimSpace(next)
+	if next == "" {
+		return values
+	}
+	for _, value := range values {
+		if strings.TrimSpace(value) == next {
+			return values
+		}
+	}
+	return append(values, next)
+}
+
+func stringValue(value any) string {
+	if value == nil {
+		return ""
+	}
+	return strings.TrimSpace(fmt.Sprint(value))
+}
+
+func intValue(value any, fallback int) int {
+	switch v := value.(type) {
+	case float64:
+		if v > 0 {
+			return int(v)
+		}
+	case int:
+		if v > 0 {
+			return v
+		}
+	case string:
+		var n int
+		if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
+			return n
+		}
+	}
+	return fallback
+}
+
+func weixinInstallStatusMessage(status string) string {
+	switch status {
+	case "scaned":
+		return "已扫码,请在微信里确认。"
+	case "scaned_but_redirect":
+		return "已扫码,正在切换微信授权节点。"
+	default:
+		return "等待扫码。"
+	}
+}
diff --git a/desktop/bot_connection_app_test.go b/desktop/bot_connection_app_test.go
new file mode 100644
index 0000000..39f59d5
--- /dev/null
+++ b/desktop/bot_connection_app_test.go
@@ -0,0 +1,620 @@
+package main
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	"reasonix/internal/config"
+)
+
+func TestNormalizeBotInstallTarget(t *testing.T) {
+	cases := []struct {
+		provider     string
+		domain       string
+		wantProvider string
+		wantDomain   string
+	}{
+		{provider: "lark", wantProvider: "feishu", wantDomain: "lark"},
+		{provider: "feishu", domain: "lark", wantProvider: "feishu", wantDomain: "lark"},
+		{provider: "wechat", wantProvider: "weixin", wantDomain: "weixin"},
+		{provider: "weixin", domain: "anything", wantProvider: "weixin", wantDomain: "weixin"},
+		{provider: "unknown", domain: "unknown", wantProvider: "feishu", wantDomain: "feishu"},
+	}
+	for _, tc := range cases {
+		gotProvider, gotDomain := normalizeBotInstallTarget(tc.provider, tc.domain)
+		if gotProvider != tc.wantProvider || gotDomain != tc.wantDomain {
+			t.Fatalf("normalizeBotInstallTarget(%q,%q) = %q,%q; want %q,%q", tc.provider, tc.domain, gotProvider, gotDomain, tc.wantProvider, tc.wantDomain)
+		}
+	}
+}
+
+func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
+	pollCount := 0
+	var beginHost string
+	var pollHosts []string
+	var actions []string
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/oauth/v1/app/registration" {
+			http.NotFound(w, r)
+			return
+		}
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		switch r.Form.Get("action") {
+		case "begin":
+			beginHost = r.Header.Get("X-Test-Original-Host")
+			actions = append(actions, "begin")
+			if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" {
+				http.Error(w, "wrong begin form", http.StatusBadRequest)
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"device_code":               "dev-lark",
+				"verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE",
+				"user_code":                 "CODE",
+				"interval":                  3,
+				"expire_in":                 300,
+			})
+		case "poll":
+			pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
+			actions = append(actions, "poll")
+			if r.Form.Get("device_code") != "dev-lark" {
+				http.Error(w, "wrong device code", http.StatusBadRequest)
+				return
+			}
+			pollCount++
+			if pollCount == 1 {
+				writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"client_id":     "cli-1",
+				"client_secret": "secret-1",
+				"user_info":     map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"},
+			})
+		default:
+			http.Error(w, "unknown action", http.StatusBadRequest)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("lark", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" {
+		t.Fatalf("start result = %+v, want ok lark-capable QR result", start)
+	}
+	qrURL, err := url.Parse(start.URL)
+	if err != nil {
+		t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err)
+	}
+	query := qrURL.Query()
+	if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
+		t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query)
+	}
+	if qrURL.Host != "open.feishu.cn" {
+		t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host)
+	}
+
+	pending, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall pending: %v", err)
+	}
+	if pending.Done || pending.Status != "pending" {
+		t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
+	}
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done {
+		t.Fatalf("poll result = %+v, want done", poll)
+	}
+	if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" {
+		t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection)
+	}
+	if beginHost != "accounts.feishu.cn" {
+		t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost)
+	}
+	if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
+		t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
+	}
+	if got := strings.Join(actions, ","); got != "begin,poll,poll" {
+		t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got)
+	}
+	if poll.Connection.WorkspaceRoot != "" {
+		t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
+	}
+	if poll.Connection.Credential.AppID != "cli-1" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
+		t.Fatalf("credential = %+v, want stored Lark secret", poll.Connection.Credential)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention {
+		t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu)
+	}
+	if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" {
+		t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
+	}
+	if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil {
+		t.Fatalf("unset lark secret env: %v", err)
+	}
+	reloaded, err := config.Load()
+	if err != nil {
+		t.Fatalf("reload config: %v", err)
+	}
+	if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" {
+		t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got)
+	}
+	if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
+		t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
+	}
+}
+
+func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
+	pollCount := 0
+	var beginHost string
+	var pollHosts []string
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/oauth/v1/app/registration" {
+			http.NotFound(w, r)
+			return
+		}
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		switch r.Form.Get("action") {
+		case "begin":
+			beginHost = r.Header.Get("X-Test-Original-Host")
+			writeJSON(t, w, map[string]any{
+				"device_code":               "dev-feishu",
+				"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
+				"user_code":                 "CODE",
+				"interval":                  3,
+				"expire_in":                 300,
+			})
+		case "poll":
+			pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
+			if r.Form.Get("device_code") != "dev-feishu" {
+				http.Error(w, "wrong device code", http.StatusBadRequest)
+				return
+			}
+			pollCount++
+			if pollCount == 1 {
+				writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"client_id":     "cli-lark",
+				"client_secret": "secret-lark",
+				"user_info":     map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"},
+			})
+		default:
+			http.Error(w, "unknown action", http.StatusBadRequest)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("feishu", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" {
+		t.Fatalf("start result = %+v, want Feishu QR result", start)
+	}
+	pending, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall pending: %v", err)
+	}
+	if pending.Done || pending.Status != "pending" {
+		t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
+	}
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" {
+		t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll)
+	}
+	if beginHost != "accounts.feishu.cn" {
+		t.Fatalf("begin host = %q, want Feishu accounts host", beginHost)
+	}
+	if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
+		t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
+	}
+}
+
+func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") })
+	var hosts []string
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/oauth/v1/app/registration" {
+			http.NotFound(w, r)
+			return
+		}
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host"))
+		switch r.Form.Get("action") {
+		case "begin":
+			writeJSON(t, w, map[string]any{
+				"device_code":               "dev-feishu",
+				"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
+				"user_code":                 "CODE",
+				"interval":                  3,
+				"expire_in":                 300,
+			})
+		case "poll":
+			writeJSON(t, w, map[string]any{
+				"client_id":     "cli-feishu",
+				"client_secret": "secret-feishu",
+				"user_info":     map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"},
+			})
+		default:
+			http.Error(w, "unknown action", http.StatusBadRequest)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("feishu", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Domain != "feishu" || start.InstallID == "" {
+		t.Fatalf("start result = %+v, want ok Feishu QR result", start)
+	}
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done {
+		t.Fatalf("poll result = %+v, want done", poll)
+	}
+	if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" {
+		t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection)
+	}
+	if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
+		t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential)
+	}
+	if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" {
+		t.Fatalf("registration hosts = %q, want Feishu begin and poll", got)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" {
+		t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu)
+	}
+	if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" {
+		t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
+	}
+	if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil {
+		t.Fatalf("unset feishu secret env: %v", err)
+	}
+	reloaded, err := config.Load()
+	if err != nil {
+		t.Fatalf("reload config: %v", err)
+	}
+	if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" {
+		t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got)
+	}
+	if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
+		t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
+	}
+}
+
+func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/ilink/bot/get_bot_qrcode":
+			if r.URL.Query().Get("bot_type") != "3" {
+				http.Error(w, "missing bot type", http.StatusBadRequest)
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"qrcode":             "qr-weixin",
+				"qrcode_img_content": "data:image/png;base64,abc",
+			})
+		case "/ilink/bot/get_qrcode_status":
+			if r.URL.Query().Get("qrcode") != "qr-weixin" {
+				http.Error(w, "wrong qr", http.StatusBadRequest)
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"status":        "confirmed",
+				"ilink_bot_id":  "weixin-account",
+				"bot_token":     "token-1",
+				"ilink_user_id": "user-1",
+				"baseurl":       "https://ilinkai.weixin.qq.com",
+			})
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("weixin", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Provider != "weixin" || start.Domain != "weixin" || start.URL != "data:image/png;base64,abc" || start.DeviceCode != "qr-weixin" {
+		t.Fatalf("start result = %+v, want weixin QR result", start)
+	}
+
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done {
+		t.Fatalf("poll result = %+v, want done", poll)
+	}
+	if poll.Connection.Provider != "weixin" || poll.Connection.Domain != "weixin" || poll.Connection.Credential.AccountID != "weixin-account" {
+		t.Fatalf("connection = %+v, want weixin account connection", poll.Connection)
+	}
+	if poll.Connection.WorkspaceRoot != "" {
+		t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
+	}
+	if poll.Connection.Credential.TokenEnv != "WEIXIN_BOT_TOKEN" || !poll.Connection.Credential.SecretSet {
+		t.Fatalf("credential = %+v, want saved account to count as configured token", poll.Connection.Credential)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" {
+		t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin)
+	}
+	if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
+		t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
+	}
+	reloaded, err := config.Load()
+	if err != nil {
+		t.Fatalf("reload config: %v", err)
+	}
+	if len(reloaded.Bot.Connections) != 1 {
+		t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections)
+	}
+	reloadedConnection := botConnectionView(reloaded.Bot.Connections[0])
+	if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet {
+		t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential)
+	}
+}
+
+func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
+	qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
+	if err != nil {
+		t.Fatalf("feishuRegistrationQRCodeURL: %v", err)
+	}
+	parsed, err := url.Parse(qrURL)
+	if err != nil {
+		t.Fatalf("parse QR URL: %v", err)
+	}
+	query := parsed.Query()
+	if query.Get("user_code") != "ABCD-1234" {
+		t.Fatalf("user_code = %q, want preserved code", query.Get("user_code"))
+	}
+	if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
+		t.Fatalf("query = %v, want SDK registration metadata", query)
+	}
+}
+
+func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "")
+	app := NewApp()
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:            "feishu-lark",
+		Provider:      "feishu",
+		Domain:        "lark",
+		Label:         "Lark",
+		Enabled:       true,
+		Status:        "connected",
+		WorkspaceRoot: "/Users/alice/work/reasonix",
+		Credential: config.BotConnectionCredential{
+			AppID:        "cli-private",
+			AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
+		},
+		SessionMappings: []config.BotConnectionSessionMapping{{
+			RemoteID:      "ou-private",
+			SessionID:     "session-private",
+			Scope:         "project",
+			WorkspaceRoot: "/Users/alice/work/reasonix",
+		}},
+	}, nil); err != nil {
+		t.Fatalf("upsert connection: %v", err)
+	}
+
+	diag, err := app.DiagnoseBotConnection("feishu-lark")
+	if err != nil {
+		t.Fatalf("DiagnoseBotConnection: %v", err)
+	}
+	if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
+		t.Fatalf("diagnostic = %+v, want warning credential report", diag)
+	}
+	for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} {
+		if strings.Contains(diag.ReportDetail, leaked) {
+			t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail)
+		}
+	}
+	var payload frontendCrashPayload
+	if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
+		t.Fatalf("report detail is not structured JSON: %v", err)
+	}
+	if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" {
+		t.Fatalf("payload = %+v, want bot runtime credential label", payload)
+	}
+	for _, want := range []string{
+		"app_secret_env_configured: true",
+		"secret_available: false",
+		"workspace_scope: project",
+		"session_mappings: 1",
+		"summary: required bot credential is not available",
+	} {
+		if !strings.Contains(payload.Message, want) {
+			t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want)
+		}
+	}
+	report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail)
+	if err != nil {
+		t.Fatalf("crashReportFromDetail: %v", err)
+	}
+	if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" {
+		t.Fatalf("report = %+v, want accepted bot report", report)
+	}
+}
+
+func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) {
+	conn := config.BotConnectionConfig{
+		ID:       "feishu-lark",
+		Provider: "feishu",
+		Domain:   "lark",
+		Label:    "Lark",
+		Enabled:  true,
+		Status:   "connected",
+		Credential: config.BotConnectionCredential{
+			AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
+		},
+	}
+	diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true)
+	if diag.ReportKind != "bot" || diag.ReportDetail == "" {
+		t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag)
+	}
+	if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") {
+		t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail)
+	}
+	var payload frontendCrashPayload
+	if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
+		t.Fatalf("report detail is not structured JSON: %v", err)
+	}
+	if !strings.Contains(payload.ErrorMessage, "[redacted-env]") {
+		t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage)
+	}
+}
+
+func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:       "weixin-weixin",
+		Provider: "weixin",
+		Domain:   "weixin",
+		Label:    "微信",
+		Enabled:  true,
+		Status:   "connected",
+		Credential: config.BotConnectionCredential{
+			AccountID: "missing-account",
+		},
+	}, nil); err != nil {
+		t.Fatalf("upsert connection: %v", err)
+	}
+
+	diag, err := app.DiagnoseBotConnection("weixin-weixin")
+	if err != nil {
+		t.Fatalf("DiagnoseBotConnection: %v", err)
+	}
+	if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
+		t.Fatalf("diagnostic = %+v, want missing local credential warning", diag)
+	}
+	if strings.Contains(diag.ReportDetail, "missing-account") {
+		t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail)
+	}
+}
+
+func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:       "feishu-lark",
+		Provider: "feishu",
+		Domain:   "lark",
+		Label:    "kun",
+		Enabled:  true,
+		Status:   "connected",
+	}, nil); err != nil {
+		t.Fatalf("upsert global connection: %v", err)
+	}
+	if err := app.rememberBotConnectionRemote("feishu-lark", "ou_global"); err != nil {
+		t.Fatalf("remember global remote: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if got := cfg.Bot.Connections[0].SessionMappings[0]; got.Scope != "global" || got.WorkspaceRoot != "" || got.RemoteID != "ou_global" {
+		t.Fatalf("global mapping = %+v, want scope=global without workspace", got)
+	}
+
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:            "weixin-project",
+		Provider:      "weixin",
+		Domain:        "weixin",
+		Label:         "project",
+		Enabled:       true,
+		Status:        "connected",
+		WorkspaceRoot: "/tmp/reasonix-project",
+	}, nil); err != nil {
+		t.Fatalf("upsert project connection: %v", err)
+	}
+	if err := app.rememberBotConnectionRemote("weixin-project", "wxid_project"); err != nil {
+		t.Fatalf("remember project remote: %v", err)
+	}
+	cfg = config.LoadForEdit(config.UserConfigPath())
+	var projectMapping config.BotConnectionSessionMapping
+	for _, conn := range cfg.Bot.Connections {
+		if conn.ID == "weixin-project" && len(conn.SessionMappings) == 1 {
+			projectMapping = conn.SessionMappings[0]
+		}
+	}
+	if projectMapping.Scope != "project" || projectMapping.WorkspaceRoot != "/tmp/reasonix-project" || projectMapping.RemoteID != "wxid_project" {
+		t.Fatalf("project mapping = %+v, want project scope and workspace", projectMapping)
+	}
+}
+
+func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
+	t.Helper()
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(value); err != nil {
+		t.Fatalf("write json: %v", err)
+	}
+}
+
+func withRewrittenHTTP(t *testing.T, handler http.Handler) {
+	t.Helper()
+	server := httptest.NewServer(handler)
+	target, err := url.Parse(server.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	previous := http.DefaultTransport
+	http.DefaultTransport = rewriteHTTPTransport{target: target, next: previous}
+	t.Cleanup(func() {
+		http.DefaultTransport = previous
+		server.Close()
+	})
+}
+
+type rewriteHTTPTransport struct {
+	target *url.URL
+	next   http.RoundTripper
+}
+
+func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	clone := req.Clone(req.Context())
+	clone.Header.Set("X-Test-Original-Host", req.URL.Host)
+	clone.URL.Scheme = r.target.Scheme
+	clone.URL.Host = r.target.Host
+	clone.Host = r.target.Host
+	if r.next == nil {
+		r.next = http.DefaultTransport
+	}
+	clone.URL.Path = "/" + strings.TrimLeft(clone.URL.Path, "/")
+	return r.next.RoundTrip(clone)
+}
diff --git a/desktop/bot_event_sink.go b/desktop/bot_event_sink.go
new file mode 100644
index 0000000..f95c9b3
--- /dev/null
+++ b/desktop/bot_event_sink.go
@@ -0,0 +1,179 @@
+package main
+
+import (
+	"context"
+	"log"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+const botForwardSendTimeout = 30 * time.Second
+const botForwardQueueSize = 64
+
+// ── Forward target ──────────────────────────────────────────────────────────
+
+// botForwardTarget identifies one remote chat to send forwarded events to.
+type botForwardTarget struct {
+	ConnID   string
+	Domain   string
+	ChatID   string
+	ChatType bot.ChatType
+}
+
+// ── Event forwarder ─────────────────────────────────────────────────────────
+
+// botEventForwarder implements event.Sink and forwards relevant events to
+// connected bot channels through the desktopBotRuntime. It is attached to a
+// tabEventSink when a heartbeat task should push AI output to IM channels.
+//
+// It accumulates Text events and sends them as complete messages on TurnDone
+// (and occasionally during generation when the buffer grows large enough), so
+// the remote side sees progressive streaming output rather than one big blob.
+type botEventForwarder struct {
+	runtime *desktopBotRuntime
+	targets []botForwardTarget
+
+	mu        sync.Mutex
+	buf       strings.Builder
+	queueMu   sync.Mutex
+	queue     chan string
+	closed    bool
+	closeOnce sync.Once
+}
+
+// newBotEventForwarder creates a forwarder that sends to all given targets.
+// runtime may be nil — Emit calls are then no-ops.
+func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
+	f := &botEventForwarder{
+		runtime: runtime,
+		targets: targets,
+		queue:   make(chan string, botForwardQueueSize),
+	}
+	go f.run()
+	return f
+}
+
+// Emit implements event.Sink. It forwards text and lifecycle events to the
+// connected bot channels; reasoning, tool dispatch, and other internal events
+// are dropped to avoid noisy IM output.
+func (f *botEventForwarder) Emit(e event.Event) {
+	if f.runtime == nil || len(f.targets) == 0 {
+		return
+	}
+	switch e.Kind {
+	case event.TurnStarted:
+		f.mu.Lock()
+		f.buf.Reset()
+		f.mu.Unlock()
+
+	case event.Text:
+		f.mu.Lock()
+		f.buf.WriteString(e.Text)
+		size := f.buf.Len()
+		f.mu.Unlock()
+		// Flush opportunistically when the buffer crosses a threshold, so long
+		// streams (e.g. "tell me three jokes") produce multiple messages.
+		if size >= 400 {
+			f.flush()
+		}
+
+	case event.TurnDone:
+		f.flush()
+		f.Close()
+
+	case event.ApprovalRequest:
+		// The heartbeat turn belongs to the desktop tab controller, not the bot
+		// gateway session, so remote /approve replies cannot satisfy this ID.
+		text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
+		text += "\n请回到桌面窗口处理。"
+		f.sendToAll(text)
+
+	case event.AskRequest:
+		var qb strings.Builder
+		qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
+		for i, q := range e.Ask.Questions {
+			if i > 0 {
+				qb.WriteString("\n")
+			}
+			qb.WriteString(q.Prompt)
+		}
+		qb.WriteString("\n请回到桌面窗口处理。")
+		f.sendToAll(qb.String())
+
+	case event.Notice:
+		if e.Level == event.LevelWarn {
+			f.sendToAll("⚠️ " + e.Text)
+		}
+
+	case event.CompactionStarted:
+		f.sendToAll("🔄 正在压缩上下文...")
+	}
+}
+
+// flush sends the accumulated buffer as one message per target channel.
+func (f *botEventForwarder) flush() {
+	f.mu.Lock()
+	text := strings.TrimSpace(f.buf.String())
+	if text == "" {
+		f.mu.Unlock()
+		return
+	}
+	f.buf.Reset()
+	f.mu.Unlock()
+
+	f.sendToAll(text)
+}
+
+// sendToAll dispatches text to every target channel. Errors are logged and
+// non-fatal; a failed target does not block other targets.
+func (f *botEventForwarder) sendToAll(text string) {
+	text = strings.TrimSpace(text)
+	if f.runtime == nil || len(f.targets) == 0 || text == "" {
+		return
+	}
+	f.queueMu.Lock()
+	defer f.queueMu.Unlock()
+	if f.closed {
+		return
+	}
+	select {
+	case f.queue <- text:
+	default:
+		log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
+	}
+}
+
+func (f *botEventForwarder) run() {
+	for text := range f.queue {
+		f.sendToAllNow(text)
+	}
+}
+
+func (f *botEventForwarder) sendToAllNow(text string) {
+	for _, tgt := range f.targets {
+		ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
+		_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
+			ChatID:   tgt.ChatID,
+			ChatType: tgt.ChatType,
+			Text:     text,
+		})
+		cancel()
+		if err != nil {
+			log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
+		}
+	}
+}
+
+func (f *botEventForwarder) Close() {
+	f.closeOnce.Do(func() {
+		f.flush()
+		f.queueMu.Lock()
+		f.closed = true
+		close(f.queue)
+		f.queueMu.Unlock()
+	})
+}
diff --git a/desktop/bot_event_sink_test.go b/desktop/bot_event_sink_test.go
new file mode 100644
index 0000000..f2fd5e0
--- /dev/null
+++ b/desktop/bot_event_sink_test.go
@@ -0,0 +1,133 @@
+package main
+
+import (
+	"context"
+	"io"
+	"log/slog"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+type desktopForwardTestAdapter struct {
+	platform bot.Platform
+	name     string
+	messages chan bot.InboundMessage
+	entered  chan struct{}
+	release  chan struct{}
+	sent     chan bot.OutboundMessage
+	once     sync.Once
+}
+
+func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
+	return &desktopForwardTestAdapter{
+		platform: bot.PlatformFeishu,
+		name:     "forward-test",
+		messages: make(chan bot.InboundMessage),
+		entered:  make(chan struct{}),
+		release:  make(chan struct{}),
+		sent:     make(chan bot.OutboundMessage, 8),
+	}
+}
+
+func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
+func (a *desktopForwardTestAdapter) Name() string           { return a.name }
+func (a *desktopForwardTestAdapter) Start(context.Context) error {
+	return nil
+}
+func (a *desktopForwardTestAdapter) Stop() error { return nil }
+func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
+	return nil
+}
+func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
+	return a.messages
+}
+func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
+	a.once.Do(func() { close(a.entered) })
+	select {
+	case <-a.release:
+	case <-ctx.Done():
+		return bot.SendResult{}, ctx.Err()
+	}
+	a.sent <- msg
+	return bot.SendResult{MessageID: "sent"}, nil
+}
+
+func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
+	gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
+		ID:       "feishu-lark",
+		Domain:   "lark",
+		Platform: bot.PlatformFeishu,
+		Adapter:  adapter,
+	}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+	return &desktopBotRuntime{gw: gw}
+}
+
+func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
+	adapter := newDesktopForwardTestAdapter()
+	forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
+		ConnID:   "feishu-lark",
+		Domain:   "lark",
+		ChatID:   "oc-group-1",
+		ChatType: bot.ChatGroup,
+	}})
+
+	done := make(chan struct{})
+	go func() {
+		forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
+		close(done)
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("Emit blocked behind slow bot send")
+	}
+	select {
+	case <-adapter.entered:
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("adapter send did not start")
+	}
+
+	forwarder.Close()
+	close(adapter.release)
+	select {
+	case <-adapter.sent:
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("queued bot message was not sent after adapter release")
+	}
+}
+
+func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
+	adapter := newDesktopForwardTestAdapter()
+	close(adapter.release)
+	forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
+		ConnID:   "feishu-lark",
+		Domain:   "lark",
+		ChatID:   "oc-group-1",
+		ChatType: bot.ChatGroup,
+	}})
+
+	forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
+		ID:      "approval-1",
+		Tool:    "shell",
+		Subject: "run command",
+	}})
+	forwarder.Close()
+
+	select {
+	case msg := <-adapter.sent:
+		if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
+			t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
+		}
+		if !strings.Contains(msg.Text, "桌面") {
+			t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
+		}
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("approval notice was not sent")
+	}
+}
diff --git a/desktop/bot_runtime_app.go b/desktop/bot_runtime_app.go
new file mode 100644
index 0000000..8a4089a
--- /dev/null
+++ b/desktop/bot_runtime_app.go
@@ -0,0 +1,415 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+)
+
+type BotRuntimeStatusView struct {
+	Running     bool   `json:"running"`
+	Status      string `json:"status"`
+	Message     string `json:"message"`
+	Connections int    `json:"connections"`
+	StartedAt   string `json:"startedAt"`
+}
+
+type desktopBotRuntime struct {
+	// lifecycleMu serializes start/stop transitions so two apply/stop calls
+	// can't race a gateway into existence. The slow work (gw.Stop teardown,
+	// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
+	// never block on a restart.
+	lifecycleMu sync.Mutex
+	mu          sync.Mutex
+	cancel      context.CancelFunc
+	gw          *bot.BotGateway
+	status      BotRuntimeStatusView
+}
+
+func newDesktopBotRuntime() *desktopBotRuntime {
+	return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
+}
+
+func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
+	channel := bot.ChannelConfig{
+		Model:            strings.TrimSpace(qq.Model),
+		ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
+		WorkspaceRoot:    strings.TrimSpace(qq.WorkspaceRoot),
+	}
+	if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
+		return channels, connectionChannels
+	}
+	if channels == nil {
+		channels = make(map[bot.Platform]bot.ChannelConfig)
+	}
+	if _, ok := channels[bot.PlatformQQ]; !ok {
+		channels[bot.PlatformQQ] = channel
+	}
+	if connectionChannels == nil {
+		connectionChannels = make(map[string]bot.ChannelConfig)
+	}
+	if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
+		connectionChannels[string(bot.PlatformQQ)] = channel
+	}
+	return channels, connectionChannels
+}
+
+func (a *App) refreshBotRuntimeAsync() {
+	if a.ctx == nil {
+		return
+	}
+	a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
+}
+
+func (a *App) refreshBotRuntime() {
+	// NewApp always pre-fills botRuntime; a nil here means a test-constructed
+	// App with no bot runtime, which must not lazily create one from a
+	// background goroutine (that would race a concurrent refresh).
+	if a.botRuntime == nil {
+		return
+	}
+	var watcherVersion uint64
+	if a.botBridge != nil {
+		watcherVersion = a.botBridge.watcherVersion()
+	}
+	cfg, err := a.loadDesktopBotConfig()
+	if err != nil {
+		a.botRuntime.stop("error", err.Error())
+		return
+	}
+	// Assign through a typed local so a nil *botBridgeHub never becomes a
+	// non-nil bot.DesktopBridge interface inside the gateway config.
+	var bridge bot.DesktopBridge
+	if a.botBridge != nil {
+		// 配置是订阅的持久化事实源:每次运行时重算前重新种子,桌面重启后
+		// /desktop watch 的订阅继续生效。
+		a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
+		bridge = a.botBridge
+	}
+	_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
+}
+
+func (a *App) loadDesktopBotConfig() (*config.Config, error) {
+	// Read-only load feeding the bot runtime and connection diagnostics. It
+	// must load credentials: the runtime resolves app secrets and control
+	// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
+	// credential-free view load would leave unset on a fresh process.
+	cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
+	if err != nil {
+		return nil, err
+	}
+	return cfg, nil
+}
+
+func (a *App) stopBotRuntime() {
+	if a.botRuntime != nil {
+		a.botRuntime.stop("stopped", "bot runtime stopped")
+	}
+}
+
+func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
+	if a.botRuntime == nil {
+		return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}
+	}
+	return a.botRuntime.snapshot()
+}
+
+func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
+	if r == nil {
+		return nil
+	}
+	if parent == nil {
+		parent = context.Background()
+	}
+	plan := desktopBotRuntimePlan(cfg)
+	r.lifecycleMu.Lock()
+	defer r.lifecycleMu.Unlock()
+	r.stopCurrent()
+	if !plan.Start {
+		r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
+		return nil
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
+	ctx, cancel := context.WithCancel(parent)
+	modelName := botruntime.ModelName(cfg, "")
+	channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
+	connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
+	channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
+	gwCfg := bot.GatewayConfig{
+		Model:              modelName,
+		ToolApprovalMode:   cfg.Bot.ToolApprovalMode,
+		MaxSteps:           cfg.Bot.MaxSteps,
+		QueueMode:          cfg.Bot.QueueMode,
+		QueueCap:           cfg.Bot.QueueCap,
+		QueueDrop:          cfg.Bot.QueueDrop,
+		PairingEnabled:     cfg.Bot.Pairing.Enabled,
+		PairingTTL:         time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
+		PairingMaxPending:  cfg.Bot.Pairing.MaxPendingPerPlatform,
+		IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
+		SelfUserIDs: map[bot.Platform][]string{
+			bot.PlatformQQ:     cfg.Bot.SelfUserIDs.QQ,
+			bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
+			bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
+		},
+		ControlEnabled:     cfg.Bot.Control.Enabled,
+		ControlAddr:        cfg.Bot.Control.Addr,
+		ControlToken:       os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
+		WorkspaceRoot:      workspaceRoot,
+		Channels:           channels,
+		ConnectionChannels: connectionChannels,
+		Routes:             botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
+		ConnectionAccess:   botruntime.ConnectionAccessConfigs(cfg),
+		Enabled:            plan.Enabled,
+		Allowlist: bot.AllowlistConfig{
+			Enabled:  cfg.Bot.Allowlist.Enabled,
+			AllowAll: cfg.Bot.Allowlist.AllowAll,
+			Users: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQUsers,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
+			},
+			Approvers: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQApprovers,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
+			},
+			Admins: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQAdmins,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
+			},
+			Groups: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQGroups,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
+			},
+		},
+		Debounce:                 time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
+		OnInbound:                botruntime.NewRemoteRememberer(logger),
+		OnSessionReady:           botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
+		OnToolApprovalModeChange: onToolApprovalModeChange,
+		Desktop:                  bridge,
+	}
+	bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
+	if len(bindings) == 0 {
+		cancel()
+		r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
+		return nil
+	}
+	gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
+	if err := gw.Start(ctx); err != nil {
+		cancel()
+		gw.Stop()
+		r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
+		return err
+	}
+	runningConnections := gw.AdapterCount()
+	startErrors := gw.StartErrors()
+	status := "running"
+	message := fmt.Sprintf("%d bot connection(s) running", runningConnections)
+	if len(startErrors) > 0 {
+		status = "degraded"
+		message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
+	}
+	r.mu.Lock()
+	r.cancel = cancel
+	r.gw = gw
+	r.status = BotRuntimeStatusView{
+		Running:     true,
+		Status:      status,
+		Message:     message,
+		Connections: runningConnections,
+		StartedAt:   time.Now().UTC().Format(time.RFC3339),
+	}
+	r.mu.Unlock()
+	return nil
+}
+
+func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error {
+	mode = normalizeBotConnectionToolApprovalMode(mode)
+	if mode == "" {
+		return nil
+	}
+	return a.applyConfigOnly(func(c *config.Config) error {
+		id := strings.TrimSpace(msg.ConnectionID)
+		now := time.Now().UTC().Format(time.RFC3339)
+		if id != "" {
+			for i := range c.Bot.Connections {
+				if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id {
+					c.Bot.Connections[i].ToolApprovalMode = mode
+					c.Bot.Connections[i].UpdatedAt = now
+					return nil
+				}
+			}
+		}
+		c.Bot.ToolApprovalMode = mode
+		return nil
+	})
+}
+
+func summarizeBotRuntimeErrors(errs []error) string {
+	parts := make([]string, 0, len(errs))
+	for _, err := range errs {
+		if err == nil {
+			continue
+		}
+		parts = append(parts, err.Error())
+	}
+	if len(parts) == 0 {
+		return ""
+	}
+	if len(parts) > 3 {
+		hidden := len(parts) - 3
+		parts = append(parts[:3], fmt.Sprintf("%d more", hidden))
+	}
+	return strings.Join(parts, "; ")
+}
+
+type botRuntimePlan struct {
+	Start   bool
+	Status  string
+	Message string
+	Enabled map[bot.Platform]bool
+}
+
+func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
+	if cfg == nil {
+		return botRuntimePlan{Status: "error", Message: "config is unavailable"}
+	}
+	if !cfg.Bot.Enabled {
+		return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
+	}
+	if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
+		return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
+	}
+	enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
+	if len(unknown) > 0 {
+		return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")}
+	}
+	if !botruntime.HasEnabledPlatform(enabled) {
+		return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"}
+	}
+	return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled}
+}
+
+func (r *desktopBotRuntime) stop(status, message string) {
+	r.lifecycleMu.Lock()
+	defer r.lifecycleMu.Unlock()
+	r.stopCurrent()
+	r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
+}
+
+// stopCurrent detaches the running gateway under r.mu, then tears it down
+// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
+// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
+func (r *desktopBotRuntime) stopCurrent() {
+	r.mu.Lock()
+	cancel := r.cancel
+	gw := r.gw
+	r.cancel = nil
+	r.gw = nil
+	r.mu.Unlock()
+	if cancel != nil {
+		cancel()
+	}
+	if gw != nil {
+		gw.Stop()
+	}
+}
+
+func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
+	r.mu.Lock()
+	r.status = status
+	r.mu.Unlock()
+}
+
+func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return r.status
+}
+
+// updateConnectionToolApprovalMode updates a connection's tool approval mode
+// on the running gateway without restarting. Returns true if updated, false if
+// the gateway is not running or the connection is unknown.
+func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.gw == nil {
+		return false
+	}
+	mode = normalizeBotConnectionToolApprovalMode(mode)
+	// Update ConnectionChannels in the internal GatewayConfig so new sessions
+	// pick up the mode. Existing sessions are updated by the gateway directly.
+	r.gw.UpdateConnectionToolApprovalMode(connID, mode)
+	return true
+}
+
+// SendToAdapter sends a message through the running gateway's adapter
+// identified by connID. Returns an error if the gateway is not running
+// or no matching adapter is found.
+func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
+	r.mu.Lock()
+	gw := r.gw
+	r.mu.Unlock()
+	if gw == nil {
+		return bot.SendResult{}, nil // gateway not running — silent no-op
+	}
+	return gw.SendToAdapter(ctx, connID, domain, msg)
+}
+
+// Running returns true if the bot gateway is currently active.
+func (r *desktopBotRuntime) Running() bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return r.gw != nil
+}
+
+// ForwardTargets returns the list of bot forward targets derived from the
+// current config's bot connections and their session mappings. Each mapping
+// produces one target (connID + chatID + chatType) for event forwarding.
+func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
+	if cfg == nil {
+		return nil
+	}
+	var targets []botForwardTarget
+	seen := make(map[botForwardTarget]bool)
+	for _, conn := range cfg.Bot.Connections {
+		if !conn.Enabled {
+			continue
+		}
+		connID := botruntime.ConnectionRuntimeID(conn)
+		domain := strings.TrimSpace(conn.Domain)
+		for _, sm := range conn.SessionMappings {
+			remoteID := strings.TrimSpace(sm.RemoteID)
+			if remoteID == "" {
+				continue
+			}
+			chatType := bot.ChatDM
+			if sm.ChatType != "" {
+				chatType = bot.ChatType(sm.ChatType)
+			}
+			target := botForwardTarget{
+				ConnID:   connID,
+				Domain:   domain,
+				ChatID:   remoteID,
+				ChatType: chatType,
+			}
+			if seen[target] {
+				continue
+			}
+			seen[target] = true
+			targets = append(targets, target)
+		}
+	}
+	return targets
+}
diff --git a/desktop/bot_runtime_app_test.go b/desktop/bot_runtime_app_test.go
new file mode 100644
index 0000000..2919848
--- /dev/null
+++ b/desktop/bot_runtime_app_test.go
@@ -0,0 +1,504 @@
+package main
+
+import (
+	"errors"
+	"io"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+)
+
+func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = true
+	cfg.Bot.Allowlist.Enabled = true
+	cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
+	cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+		{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if !plan.Start {
+		t.Fatalf("plan = %+v, want start", plan)
+	}
+	if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
+		t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
+	}
+}
+
+func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = true
+	cfg.Bot.Allowlist.Enabled = true
+	cfg.Bot.Pairing.Enabled = false
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if plan.Start || plan.Status != "blocked" {
+		t.Fatalf("plan = %+v, want blocked without allowlist", plan)
+	}
+}
+
+func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = true
+	cfg.Bot.Allowlist.Enabled = true
+	cfg.Bot.Pairing.Enabled = true
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if !plan.Start {
+		t.Fatalf("plan = %+v, want start with pairing enabled", plan)
+	}
+}
+
+func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
+	channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
+		Model:            "qq-model",
+		ToolApprovalMode: "auto",
+		WorkspaceRoot:    "/tmp/qq-project",
+	}, nil, nil)
+
+	channel, ok := channels[bot.PlatformQQ]
+	if !ok {
+		t.Fatalf("platform QQ channel missing: %+v", channels)
+	}
+	if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
+		t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
+	}
+	connectionChannel, ok := connectionChannels["qq"]
+	if !ok {
+		t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
+	}
+	if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
+		t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
+	}
+}
+
+func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = false
+	cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if plan.Start || plan.Status != "stopped" {
+		t.Fatalf("plan = %+v, want stopped when disabled", plan)
+	}
+}
+
+func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Connections = []config.BotConnectionConfig{{
+		ID:       "feishu-lark",
+		Provider: "feishu",
+		Domain:   "lark",
+		Enabled:  true,
+		SessionMappings: []config.BotConnectionSessionMapping{
+			{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
+			{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
+			{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
+		},
+	}}
+
+	targets := newDesktopBotRuntime().ForwardTargets(cfg)
+	if len(targets) != 2 {
+		t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
+	}
+	seen := map[string]bool{}
+	for _, target := range targets {
+		key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
+		if seen[key] {
+			t.Fatalf("duplicate target %q in %+v", key, targets)
+		}
+		seen[key] = true
+	}
+	if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
+		t.Fatalf("targets = %+v, want group and dm targets", targets)
+	}
+}
+
+func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
+	userCfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[bot]
+enabled = false
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	got, err := NewApp().loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	plan := desktopBotRuntimePlan(got)
+	if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
+		t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
+	}
+}
+
+func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() {
+		_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
+		_ = os.Unsetenv("LARK_BOT_APP_SECRET")
+	})
+
+	userCfg := config.Default()
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
+	userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
+	userCfg.Bot.Feishu.Enabled = true
+	userCfg.Bot.Weixin.Enabled = true
+	userCfg.Bot.Weixin.AccountID = "weixin-account"
+	userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
+	userCfg.Bot.Connections = []config.BotConnectionConfig{
+		{
+			ID:       "feishu-feishu",
+			Provider: "feishu",
+			Domain:   "feishu",
+			Enabled:  true,
+			Status:   "connected",
+			Credential: config.BotConnectionCredential{
+				AppID:        "cli-feishu",
+				AppSecretEnv: "FEISHU_BOT_APP_SECRET",
+			},
+		},
+		{
+			ID:       "feishu-lark",
+			Provider: "feishu",
+			Domain:   "lark",
+			Enabled:  true,
+			Status:   "connected",
+			Credential: config.BotConnectionCredential{
+				AppID:        "cli-lark",
+				AppSecretEnv: "LARK_BOT_APP_SECRET",
+			},
+		},
+		{
+			ID:       "weixin-weixin",
+			Provider: "weixin",
+			Domain:   "weixin",
+			Enabled:  true,
+			Status:   "connected",
+			Credential: config.BotConnectionCredential{
+				AccountID: "weixin-account",
+				TokenEnv:  "WEIXIN_BOT_TOKEN",
+			},
+		},
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+	if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
+		t.Fatalf("create credentials dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
+		t.Fatalf("write credentials: %v", err)
+	}
+	weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
+	if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
+		t.Fatalf("create weixin account dir: %v", err)
+	}
+	if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
+		t.Fatalf("write weixin account: %v", err)
+	}
+	_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
+	_ = os.Unsetenv("LARK_BOT_APP_SECRET")
+
+	got, err := NewApp().loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	views := botConnectionViews(got.Bot.Connections)
+	if len(views) != 3 {
+		t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
+	}
+	for _, view := range views {
+		if !view.Credential.SecretSet {
+			t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
+		}
+	}
+	plan := desktopBotRuntimePlan(got)
+	if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
+		t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
+	}
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
+	if len(bindings) != 3 {
+		t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
+	}
+}
+
+func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.Default()
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+label = "Lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	app := NewApp()
+	got, err := app.loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
+	}
+
+	// The bot-runtime load is a pure read: the merge above stays in memory and
+	// the user config file is not rewritten.
+	preWrite := config.LoadForEdit(config.UserConfigPath())
+	if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
+		t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
+	}
+
+	// The first locked write path performs the on-disk migration.
+	if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
+		t.Fatalf("applyConfigOnly: %v", err)
+	}
+	persisted := config.LoadForEdit(config.UserConfigPath())
+	if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
+	}
+	if persisted.DesktopTheme() != "dark" {
+		t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
+	}
+}
+
+func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[desktop]
+theme = "dark"
+
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+label = "Lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	app := NewApp()
+	got, err := app.loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
+	}
+
+	// The bot-runtime load is a pure read: it serves the legacy config from
+	// memory and must not create the user config file.
+	if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
+		t.Fatalf("read path must not create the user config, stat err = %v", err)
+	}
+
+	// The first locked write path creates the user config with the migrated
+	// bot settings (adopting the legacy config, ConfigVersion-bumped).
+	if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
+		t.Fatalf("applyConfigOnly: %v", err)
+	}
+	persisted := config.LoadForEdit(config.UserConfigPath())
+	if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
+	}
+}
+
+func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[desktop]
+theme = "dark"
+close_behavior = "quit"
+
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+label = "Lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	settings := NewApp().Settings()
+	if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
+	}
+	if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
+		t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
+	}
+
+	persisted := config.LoadForEdit(config.UserConfigPath())
+	if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
+		t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
+	}
+}
+
+func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.Default()
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
+	userCfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	got, err := NewApp().loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
+		t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
+	}
+}
+
+func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
+	got := summarizeBotRuntimeErrors([]error{
+		errors.New("first"),
+		nil,
+		errors.New("second"),
+		errors.New("third"),
+		errors.New("fourth"),
+	})
+
+	for _, want := range []string{"first", "second", "third", "1 more"} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("summary = %q, want %q", got, want)
+		}
+	}
+	if strings.Contains(got, "fourth") {
+		t.Fatalf("summary = %q, should cap extra errors", got)
+	}
+}
diff --git a/desktop/bound_array_contract_test.go b/desktop/bound_array_contract_test.go
new file mode 100644
index 0000000..d5b1f43
--- /dev/null
+++ b/desktop/bound_array_contract_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+	"encoding/json"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	cases := []struct {
+		name string
+		got  any
+	}{
+		{"Checkpoints", app.Checkpoints()},
+		{"ListSessions", app.ListSessions()},
+		{"ListTrashedSessions", app.ListTrashedSessions()},
+		{"ListWorkspaces", app.ListWorkspaces()},
+		{"History", app.History()},
+		{"Jobs", app.Jobs()},
+		{"Commands", app.Commands()},
+		{"Models", app.Models()},
+		{"ListDir", app.ListDir("__missing__")},
+		{"ListDirForTab", app.ListDirForTab("missing", "")},
+		{"SearchFileRefsForTab", app.SearchFileRefsForTab("missing", "file")},
+		{"ListTabs", app.ListTabs()},
+		{"ListProjectTree", app.ListProjectTree()},
+		{"AvailableSubagentTools", app.AvailableSubagentTools()},
+		{"AutoResearchList", app.AutoResearchList("missing")},
+		{"AutoResearchFindings", app.AutoResearchFindings("missing", 10)},
+		{"MCPServers", app.MCPServers()},
+		{"Plugins", app.Plugins()},
+		{"HeartbeatListTasks", app.HeartbeatListTasks()},
+		{"HeartbeatReloadTasks", app.HeartbeatReloadTasks()},
+	}
+	for _, tc := range cases {
+		assertNonNilSliceJSON(t, tc.name, tc.got)
+	}
+
+	if got := app.SlashArgs("/skill "); got.Items == nil {
+		t.Fatal("SlashArgs().Items is nil; frontend expects []")
+	}
+	if got := app.WorkspaceChanges(""); got.Files == nil {
+		t.Fatal(`WorkspaceChanges("").Files is nil; frontend expects []`)
+	}
+	if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
+		t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
+	}
+	if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
+		t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
+	}
+	if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
+		got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
+		got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
+		got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
+		got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
+		t.Fatalf("Settings() contains nil array fields: %+v", got)
+	}
+	if got := app.DesktopStartupSettings(); got.StatusBarItems == nil ||
+		got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
+		got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
+		t.Fatalf("DesktopStartupSettings() contains nil array fields: %+v", got)
+	}
+
+	boundPayloads := []struct {
+		name string
+		got  any
+	}{
+		{"CapabilityDiagnostics", app.CapabilityDiagnostics(false)},
+		{"Capabilities", app.Capabilities()},
+		{"SkillsSettings", app.SkillsSettings()},
+		{"HistoryPage", app.HistoryPage(0, 20)},
+		{"Effort", app.Effort()},
+		{"Memory", app.Memory()},
+		{"MemorySuggestions", app.MemorySuggestions()},
+	}
+	for _, tc := range boundPayloads {
+		assertRequiredJSONSlicesNonNil(t, tc.name, reflect.ValueOf(tc.got))
+	}
+}
+
+func assertNonNilSliceJSON(t *testing.T, name string, got any) {
+	t.Helper()
+	v := reflect.ValueOf(got)
+	if v.Kind() != reflect.Slice {
+		t.Fatalf("%s returned %T, want slice", name, got)
+	}
+	if v.IsNil() {
+		t.Fatalf("%s returned nil slice; frontend expects []", name)
+	}
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("%s JSON marshal: %v", name, err)
+	}
+	if string(raw) == "null" {
+		t.Fatalf("%s JSON encoded as null; frontend expects []", name)
+	}
+}
+
+func assertRequiredJSONSlicesNonNil(t *testing.T, path string, value reflect.Value) {
+	t.Helper()
+	for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
+		if value.IsNil() {
+			return
+		}
+		value = value.Elem()
+	}
+
+	switch value.Kind() {
+	case reflect.Slice, reflect.Array:
+		if value.Kind() == reflect.Slice && value.IsNil() {
+			t.Fatalf("%s is a nil slice; JSON contract requires []", path)
+		}
+		for i := 0; i < value.Len(); i++ {
+			assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
+		}
+	case reflect.Struct:
+		typ := value.Type()
+		for i := 0; i < value.NumField(); i++ {
+			fieldType := typ.Field(i)
+			if fieldType.PkgPath != "" {
+				continue
+			}
+			jsonTag := fieldType.Tag.Get("json")
+			if jsonTag == "-" || strings.Contains(jsonTag, ",omitempty") {
+				continue
+			}
+			fieldPath := path + "." + fieldType.Name
+			assertRequiredJSONSlicesNonNil(t, fieldPath, value.Field(i))
+		}
+	}
+}
diff --git a/desktop/build/appicon.png b/desktop/build/appicon.png
new file mode 100644
index 0000000..fc06d8f
Binary files /dev/null and b/desktop/build/appicon.png differ
diff --git a/desktop/build/appicon.svg b/desktop/build/appicon.svg
new file mode 100644
index 0000000..05d6b1f
--- /dev/null
+++ b/desktop/build/appicon.svg
@@ -0,0 +1,7 @@
+
+
+  
+    
+    
+  
+
diff --git a/desktop/build/darwin/entitlements.plist b/desktop/build/darwin/entitlements.plist
new file mode 100644
index 0000000..1a05582
--- /dev/null
+++ b/desktop/build/darwin/entitlements.plist
@@ -0,0 +1,17 @@
+
+
+
+
+	
+	com.apple.security.cs.allow-jit
+	
+	com.apple.security.cs.allow-unsigned-executable-memory
+	
+	com.apple.security.cs.disable-library-validation
+	
+
+
diff --git a/desktop/build/darwin/icon.icns b/desktop/build/darwin/icon.icns
new file mode 100644
index 0000000..20dba57
Binary files /dev/null and b/desktop/build/darwin/icon.icns differ
diff --git a/desktop/build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png
new file mode 100644
index 0000000..29c3661
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png
new file mode 100644
index 0000000..96c565b
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png
new file mode 100644
index 0000000..0e6ea88
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png
new file mode 100644
index 0000000..6ea84a5
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png
new file mode 100644
index 0000000..6e25bf2
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png
new file mode 100644
index 0000000..798d1da
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png
new file mode 100644
index 0000000..5f34a9a
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png
new file mode 100644
index 0000000..065dcf3
Binary files /dev/null and b/desktop/build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png differ
diff --git a/desktop/build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg b/desktop/build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg
new file mode 100644
index 0000000..05d6b1f
--- /dev/null
+++ b/desktop/build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg
@@ -0,0 +1,7 @@
+
+
+  
+    
+    
+  
+
diff --git a/desktop/build/linux/nfpm.yaml b/desktop/build/linux/nfpm.yaml
new file mode 100644
index 0000000..649c2e4
--- /dev/null
+++ b/desktop/build/linux/nfpm.yaml
@@ -0,0 +1,77 @@
+# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
+# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
+# below are relative to desktop/). The .deb is a human-download artifact only — like
+# the macOS .dmg — so the Linux updater channel stays the .tar.gz and cmd/sign's
+# manifest skips .deb files.
+#
+# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION is the tag
+# minus a leading "v" and any prerelease suffix (a strict X.Y.Z that dpkg accepts);
+# DEB_ARCH is the Go arch name, which already matches Debian's (amd64, arm64).
+name: "reasonix-desktop"
+arch: "${DEB_ARCH}"
+platform: "linux"
+version: "${DEB_VERSION}"
+section: "utils"
+priority: "optional"
+maintainer: "esengine "
+description: |
+  Reasonix desktop — a Wails shell around the Go kernel.
+vendor: "Reasonix Contributors"
+homepage: "https://github.com/esengine/DeepSeek-Reasonix"
+license: "MIT"
+# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
+# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
+depends:
+  - libgtk-3-0
+  - libwebkit2gtk-4.1-0
+contents:
+  - src: ./build/bin/reasonix-desktop
+    dst: /usr/bin/reasonix-desktop
+    file_info:
+      mode: 0755
+  - src: ./build/linux/reasonix.desktop
+    dst: /usr/share/applications/reasonix.desktop
+    file_info:
+      mode: 0644
+  # appicon.png is 1024x1024 — not a standard hicolor size dir, so install it under
+  # the size-agnostic pixmaps path that the .desktop's `Icon=reasonix-desktop` finds.
+  - src: ./build/appicon.png
+    dst: /usr/share/pixmaps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/16x16/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/24x24/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/32x32/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/48x48/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/64x64/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/128x128/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/256x256/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png
+    dst: /usr/share/icons/hicolor/512x512/apps/reasonix-desktop.png
+    file_info:
+      mode: 0644
+  - src: ./build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg
+    dst: /usr/share/icons/hicolor/scalable/apps/reasonix-desktop.svg
+    file_info:
+      mode: 0644
diff --git a/desktop/build/linux/reasonix.desktop b/desktop/build/linux/reasonix.desktop
new file mode 100644
index 0000000..806276a
--- /dev/null
+++ b/desktop/build/linux/reasonix.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=Reasonix
+Comment=Reasonix desktop — a Wails shell around the Go kernel
+Exec=reasonix-desktop
+Icon=reasonix-desktop
+Categories=Development;Utility;
+Terminal=false
+StartupWMClass=reasonix-desktop
diff --git a/desktop/build/windows/installer/project.nsi b/desktop/build/windows/installer/project.nsi
new file mode 100644
index 0000000..60b9551
--- /dev/null
+++ b/desktop/build/windows/installer/project.nsi
@@ -0,0 +1,213 @@
+Unicode true
+
+####
+## Reasonix per-user NSIS installer.
+##
+## This file is COMMITTED and customized (Wails leaves an existing project.nsi
+## untouched and only regenerates wails_tools.nsh). The customizations vs.
+## Wails' default template:
+##
+##   1. REQUEST_EXECUTION_LEVEL "user" + InstallDir under $LOCALAPPDATA - install
+##      without administrator rights. This is what lets the auto-updater re-run a
+##      freshly downloaded installer silently (`/S`) with no UAC prompt.
+##   2. Uninstall registry under HKCU (not HKLM). Wails' wails.writeUninstaller /
+##      wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
+##      cannot write - so we inline HKCU versions below instead.
+##   3. InstallDir is remembered across updates via InstallDirRegKey +
+##      InstallLocation (HKCU\...\Uninstall\InstallLocation). When upgrading from
+##      a build that did not write InstallLocation yet, .onInit falls back to the
+##      old DisplayIcon path before using the default. Without this, every release
+##      forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if they had
+##      moved the install to a different drive (e.g. D:\Tools\Reasonix); the silent
+##      auto-updater would re-run with /S into the wrong dir, leaving the old
+##      install orphaned.
+##
+## Everything else mirrors Wails' generated default. Defines below override the
+## ProjectInfo values that wails_tools.nsh would otherwise populate.
+####
+
+## Install per-user (no admin). Must be defined BEFORE including wails_tools.nsh,
+## which only sets the "admin" default when REQUEST_EXECUTION_LEVEL is undefined.
+!define REQUEST_EXECUTION_LEVEL "user"
+
+####
+## Include the wails tools (auto-generated; provides INFO_* defines and the
+## wails.* macros used below).
+####
+!include "wails_tools.nsh"
+!include "FileFunc.nsh"
+!include "LogicLib.nsh"
+
+# The version information for this two must consist of 4 parts
+VIProductVersion "${INFO_PRODUCTVERSION}.0"
+VIFileVersion    "${INFO_PRODUCTVERSION}.0"
+
+VIAddVersionKey "CompanyName"     "${INFO_COMPANYNAME}"
+VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
+VIAddVersionKey "ProductVersion"  "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "FileVersion"     "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "LegalCopyright"  "${INFO_COPYRIGHT}"
+VIAddVersionKey "ProductName"     "${INFO_PRODUCTNAME}"
+
+# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
+ManifestDPIAware true
+
+!include "MUI.nsh"
+
+!define MUI_ICON "..\icon.ico"
+!define MUI_UNICON "..\icon.ico"
+# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
+!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
+!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
+
+!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
+# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
+!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
+!insertmacro MUI_PAGE_INSTFILES # Installing page.
+!insertmacro MUI_PAGE_FINISH # Finished installation page.
+
+!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
+
+!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
+
+## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
+#!uninstfinalize 'signtool --file "%1"'
+#!finalize 'signtool --file "%1"'
+
+Name "${INFO_PRODUCTNAME}"
+OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
+!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
+!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
+!define REASONIX_UNLOCK_RETRIES 60
+InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
+InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
+ShowInstDetails show # This will always show the installation details.
+
+####
+## Per-user uninstaller registry (HKCU). Replaces wails.writeUninstaller /
+## wails.deleteUninstaller, which write HKLM and would fail without admin rights.
+####
+!macro reasonix.writeUninstaller
+    WriteUninstaller "$INSTDIR\uninstall.exe"
+
+    WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
+    WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
+    WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
+    WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+    WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+    WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
+    # Persist the resolved install path so a subsequent update picks it up
+    # via InstallDirRegKey above. Without this, every release would force the
+    # user back to %LOCALAPPDATA%\Programs\Reasonix even if they had moved
+    # the install to a different drive (e.g. D:\Tools\Reasonix). The auto-
+    # updater re-runs this installer with /S and trusts the persisted path,
+    # so it has to be present before the silent re-install.
+    WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
+
+    ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+    IntFmt $0 "0x%08X" $0
+    WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
+!macroend
+
+!macro reasonix.deleteUninstaller
+    Delete "$INSTDIR\uninstall.exe"
+    DeleteRegKey HKCU "${UNINST_KEY}"
+!macroend
+
+Function .onInit
+   !insertmacro wails.checkArchitecture
+
+   ; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
+   ; missing. Older installers still wrote DisplayIcon, so use its parent folder
+   ; as a compatibility bridge before falling back to the per-user default.
+   StrCmp $INSTDIR "" 0 done
+   ClearErrors
+   ReadRegStr $0 HKCU "${UNINST_KEY}" "DisplayIcon"
+   IfErrors fallback
+   StrCmp $0 "" fallback
+   ${GetParent} "$0" $INSTDIR
+   StrCmp $INSTDIR "" fallback done
+
+fallback:
+   StrCpy $INSTDIR "${REASONIX_DEFAULT_INSTALLDIR}"
+done:
+FunctionEnd
+
+Function reasonix.waitForExecutableUnlock
+   IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
+   StrCpy $0 0
+
+retry:
+   ClearErrors
+   FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
+   IfErrors locked
+   FileClose $1
+   Goto done
+
+locked:
+   IntOp $0 $0 + 1
+   IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
+   Sleep 1000
+   Goto retry
+
+failed:
+   IfSilent silent interactive
+
+interactive:
+   MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
+   Goto retry
+
+silent:
+   SetErrorLevel 1618
+
+abort:
+   Abort "Reasonix is still running. Close Reasonix and run the installer again."
+
+done:
+FunctionEnd
+
+Section
+    !insertmacro wails.setShellContext
+
+    !insertmacro wails.webview2runtime
+
+    Call reasonix.waitForExecutableUnlock
+
+    SetOutPath $INSTDIR
+
+    !insertmacro wails.files
+    !if /FileExists "${REASONIX_UPDATE_HELPER}"
+    File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
+    !else
+    !warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
+    !endif
+
+    CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+    CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+
+    !insertmacro wails.associateFiles
+    !insertmacro wails.associateCustomProtocols
+
+    !insertmacro reasonix.writeUninstaller
+SectionEnd
+
+Section "uninstall"
+    !insertmacro wails.setShellContext
+
+    RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
+
+    ; Precision uninstall: delete main application files
+    Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
+    Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
+
+    Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
+    Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+
+    !insertmacro wails.unassociateFiles
+    !insertmacro wails.unassociateCustomProtocols
+
+    !insertmacro reasonix.deleteUninstaller
+
+    ; Only remove the installation directory if it is empty to prevent data loss
+    RMDir $INSTDIR
+SectionEnd
diff --git a/desktop/capdiag_app.go b/desktop/capdiag_app.go
new file mode 100644
index 0000000..c6e10c0
--- /dev/null
+++ b/desktop/capdiag_app.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+	"reasonix/internal/capdiag"
+	"reasonix/internal/plugin"
+)
+
+// CapabilityDiagnostics returns a read-only capability report for the active
+// workspace. When includeSessionRuntime is true, connected/failed/deferred
+// status is merged from the active tab Host only — no new MCP processes are
+// started, and no controller rebuild or session snapshot is triggered.
+func (a *App) CapabilityDiagnostics(includeSessionRuntime bool) capdiag.Report {
+	root, host := a.activeDiagSnapshot(includeSessionRuntime)
+	opts := capdiag.Options{Root: root, Live: false}
+
+	if !includeSessionRuntime {
+		return capdiag.Collect(opts)
+	}
+
+	opts.RuntimeHost = host
+	if host == nil {
+		return capdiag.CollectWithRuntimeUnavailable(opts)
+	}
+	return capdiag.Collect(opts)
+}
+
+// activeDiagSnapshot reads workspace root and optional Host under one lock so
+// a tab switch cannot pair project A's config with project B's runtime Host.
+func (a *App) activeDiagSnapshot(includeHost bool) (root string, host *plugin.Host) {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	tab := a.activeTabLocked()
+	if tab == nil {
+		return "", nil
+	}
+	root = tab.WorkspaceRoot
+	if includeHost && tab.Ctrl != nil {
+		host = tab.Ctrl.Host()
+	}
+	return root, host
+}
diff --git a/desktop/capdiag_app_test.go b/desktop/capdiag_app_test.go
new file mode 100644
index 0000000..410ef83
--- /dev/null
+++ b/desktop/capdiag_app_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"reasonix/internal/control"
+	"reasonix/internal/plugin"
+)
+
+func TestCapabilityDiagnosticsStaticUsesWorkspaceRoot(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	root := t.TempDir()
+	if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# workspace agents\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.mu.Lock()
+	tab := &WorkspaceTab{
+		ID:            "diag",
+		Scope:         "project",
+		WorkspaceRoot: root,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{"diag": tab}
+	app.tabOrder = []string{"diag"}
+	app.activeTabID = "diag"
+	app.mu.Unlock()
+
+	report := app.CapabilityDiagnostics(false)
+	if report.Live {
+		t.Fatal("static diagnostics must not set live")
+	}
+	if report.SchemaVersion != 1 {
+		t.Fatalf("schema = %d", report.SchemaVersion)
+	}
+}
+
+func TestCapabilityDiagnosticsRuntimeUsesActiveHost(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	host := plugin.NewHost()
+	ctrl := control.New(control.Options{Host: host})
+	app.setTestCtrl(ctrl, "test-model")
+
+	report := app.CapabilityDiagnostics(true)
+	if report.SchemaVersion != 1 {
+		t.Fatalf("schema = %d", report.SchemaVersion)
+	}
+	_ = app.CapabilityDiagnostics(true)
+	if ctrl.Host() != host {
+		t.Fatal("diagnostics must reuse the same Host pointer")
+	}
+}
+
+func TestCapabilityDiagnosticsAtomicSnapshot(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	host := plugin.NewHost()
+	ctrl := control.New(control.Options{Host: host})
+	root := t.TempDir()
+	app.mu.Lock()
+	app.tabs = map[string]*WorkspaceTab{
+		"t1": {
+			ID: "t1", Scope: "project", WorkspaceRoot: root, Ready: true,
+			Ctrl: ctrl, disabledMCP: map[string]ServerView{},
+		},
+	}
+	app.tabOrder = []string{"t1"}
+	app.activeTabID = "t1"
+	app.mu.Unlock()
+
+	gotRoot, gotHost := app.activeDiagSnapshot(true)
+	if gotRoot != root {
+		t.Fatalf("root = %q, want %q", gotRoot, root)
+	}
+	if gotHost != host {
+		t.Fatal("host mismatch from atomic snapshot")
+	}
+}
+
+func TestCapabilityDiagnosticsRuntimeUnavailableWithoutTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	// Explicitly empty tabs.
+	app.mu.Lock()
+	app.tabs = map[string]*WorkspaceTab{}
+	app.tabOrder = nil
+	app.activeTabID = ""
+	app.mu.Unlock()
+
+	report := app.CapabilityDiagnostics(true)
+	found := false
+	for _, is := range report.Issues {
+		if is.Code == "mcp.runtime_unavailable" {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Fatalf("expected mcp.runtime_unavailable, issues=%+v", report.Issues)
+	}
+}
diff --git a/desktop/checkpoints_cancode_test.go b/desktop/checkpoints_cancode_test.go
new file mode 100644
index 0000000..5760db1
--- /dev/null
+++ b/desktop/checkpoints_cancode_test.go
@@ -0,0 +1,154 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strconv"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/checkpoint"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+)
+
+func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
+	t.Helper()
+	b, err := json.Marshal(c)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(ckptDir, "turn-"+strconv.Itoa(c.Turn)+".json"), b, 0o644); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
+	t.Helper()
+	raw, err := json.Marshal(metas)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var payload []struct {
+		Files json.RawMessage `json:"files"`
+	}
+	if err := json.Unmarshal(raw, &payload); err != nil {
+		t.Fatal(err)
+	}
+	for i, item := range payload {
+		if string(item.Files) == "null" {
+			t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
+		}
+		if len(item.Files) == 0 || item.Files[0] != '[' {
+			t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
+		}
+	}
+}
+
+// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
+// reverts files touched in that turn or any later one, so a turn with no file
+// changes of its own can still rewind code when a later turn changed files. The
+// desktop CanCode flag must reflect that suffix capability, not just the turn's
+// own paths.
+func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
+	dir := t.TempDir()
+	sessionPath := filepath.Join(dir, "s.jsonl")
+	ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
+	if err := os.MkdirAll(ckptDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	content := "old"
+	now := time.Now()
+	seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
+	seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
+		Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
+	seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
+
+	ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+	ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
+	ctrl.SetSessionPath(sessionPath)
+
+	app := &App{}
+	app.setTestCtrl(ctrl, "test")
+
+	metas := app.CheckpointsForTab("test")
+	if len(metas) != 3 {
+		t.Fatalf("checkpoints = %d, want 3", len(metas))
+	}
+	got := map[int]bool{}
+	for _, m := range metas {
+		got[m.Turn] = m.CanCode
+	}
+	if !got[0] {
+		t.Error("turn 0 (no files of its own) should allow code rewind — turn 1 changed files")
+	}
+	if !got[1] {
+		t.Error("turn 1 changed files, should allow code rewind")
+	}
+	if got[2] {
+		t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
+	}
+	if metas[0].TurnFileCount != 0 {
+		t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
+	}
+	if metas[1].TurnFileCount != 1 {
+		t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
+	}
+	if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
+		t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
+	}
+	if metas[0].FileCount != 1 || metas[0].FilesTruncated {
+		t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
+	}
+	if len(metas[2].Files) != 0 {
+		t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
+	}
+	assertCheckpointFilesEncodeAsArray(t, metas)
+}
+
+func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
+	dir := t.TempDir()
+	sessionPath := filepath.Join(dir, "s.jsonl")
+	ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
+	if err := os.MkdirAll(ckptDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	content := "old"
+	files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
+	for i := 0; i < checkpointFilePreviewLimit+5; i++ {
+		files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
+	}
+	now := time.Now()
+	seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
+	seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
+
+	ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+	ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
+	ctrl.SetSessionPath(sessionPath)
+
+	app := &App{}
+	app.setTestCtrl(ctrl, "test")
+
+	metas := app.CheckpointsForTab("test")
+	if len(metas) != 2 {
+		t.Fatalf("checkpoints = %d, want 2", len(metas))
+	}
+	if metas[0].FileCount != checkpointFilePreviewLimit+5 {
+		t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
+	}
+	if len(metas[0].Files) != checkpointFilePreviewLimit {
+		t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
+	}
+	if !metas[0].FilesTruncated {
+		t.Fatal("turn 0 should mark file preview as truncated")
+	}
+	if metas[0].TurnFileCount != 0 {
+		t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
+	}
+	if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
+		t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
+	}
+	assertCheckpointFilesEncodeAsArray(t, metas)
+}
diff --git a/desktop/cmd/sign/main.go b/desktop/cmd/sign/main.go
new file mode 100644
index 0000000..94ce089
--- /dev/null
+++ b/desktop/cmd/sign/main.go
@@ -0,0 +1,242 @@
+// Command sign is the CI-side signing and manifest tool for desktop releases. It
+// is never shipped in any artifact — the release workflow invokes it via
+// `go run ./cmd/sign`. It shares desktop/internal/update with the running updater
+// so the sign path and the verify path use one definition of the manifest and one
+// minisign implementation.
+//
+// Subcommands:
+//
+//	sign ...               Write .minisig for each file, signing with the
+//	                             encrypted minisign private key in $MINISIGN_PRIVATE_KEY
+//	                             (decrypted with $MINISIGN_PASSWORD).
+//
+//	manifest      Scan  for the per-platform artifacts, compute
+//	                             size + sha256, and write /latest.json with GitHub
+//	                             release download URLs. The R2 mirror step rewrites those
+//	                             URLs to the CDN afterwards (url + sig fields together).
+package main
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"aead.dev/minisign"
+
+	"reasonix/desktop/internal/update"
+)
+
+// platforms are the manifest keys we publish. A built artifact is matched to a key
+// by substring (file names embed the key, e.g. Reasonix-darwin-arm64.zip), so the
+// generator and the updater agree on update.PlatformKey output.
+var platforms = []string{"darwin-arm64", "darwin-amd64", "windows-amd64", "windows-arm64", "linux-amd64"}
+
+func main() {
+	if len(os.Args) < 2 {
+		usage()
+	}
+	var err error
+	switch os.Args[1] {
+	case "sign":
+		err = signFiles(os.Args[2:])
+	case "manifest":
+		if len(os.Args) != 5 {
+			usage()
+		}
+		err = genManifest(os.Args[2], os.Args[3], os.Args[4])
+	case "genkey":
+		if len(os.Args) != 3 {
+			usage()
+		}
+		err = genKey(os.Args[2])
+	case "verify":
+		if len(os.Args) != 3 {
+			usage()
+		}
+		err = verifyFile(os.Args[2])
+	default:
+		usage()
+	}
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "sign:", err)
+		os.Exit(1)
+	}
+}
+
+func usage() {
+	fmt.Fprintln(os.Stderr, "usage:\n  sign ...\n  manifest   \n  genkey \n  verify ")
+	os.Exit(2)
+}
+
+// verifyFile checks  against .minisig using the embedded public key —
+// the same check the updater runs before applying. A self-test that the signing
+// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
+func verifyFile(path string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return err
+	}
+	sig, err := os.ReadFile(path + ".minisig")
+	if err != nil {
+		return err
+	}
+	if err := update.Verify(data, sig); err != nil {
+		return err
+	}
+	fmt.Printf("OK: %s verifies against the embedded public key\n", path)
+	return nil
+}
+
+// genKey generates a fresh minisign key pair, writing the encrypted private key
+// (reasonix.key) and the public key (reasonix.pub) into dir. The password comes
+// from $MINISIGN_PASSWORD. The public key is printed — it's safe to publish; embed
+// it in internal/update/verify.go. The private key never leaves dir.
+func genKey(dir string) error {
+	pw := os.Getenv("MINISIGN_PASSWORD")
+	if strings.TrimSpace(pw) == "" {
+		return fmt.Errorf("genkey: MINISIGN_PASSWORD is empty")
+	}
+	pub, priv, err := minisign.GenerateKey(rand.Reader)
+	if err != nil {
+		return err
+	}
+	enc, err := minisign.EncryptKey(pw, priv)
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return err
+	}
+	keyPath := filepath.Join(dir, "reasonix.key")
+	pubPath := filepath.Join(dir, "reasonix.pub")
+	if err := os.WriteFile(keyPath, enc, 0o600); err != nil {
+		return err
+	}
+	pubText, err := pub.MarshalText()
+	if err != nil {
+		return err
+	}
+	if err := os.WriteFile(pubPath, pubText, 0o644); err != nil {
+		return err
+	}
+	fmt.Printf("private key -> %s (keep secret; this is the MINISIGN_PRIVATE_KEY value)\n", keyPath)
+	fmt.Printf("public key  -> %s\n\n", pubPath)
+	fmt.Printf("public key (embed in internal/update/verify.go, key ID %016X):\n%s\n", pub.ID(), pubText)
+	return nil
+}
+
+// signFiles writes a detached .minisig next to each input file. The private key is
+// read only from the environment — it never touches disk or argv.
+func signFiles(files []string) error {
+	if len(files) == 0 {
+		return fmt.Errorf("sign: no files given")
+	}
+	keyText := os.Getenv("MINISIGN_PRIVATE_KEY")
+	if strings.TrimSpace(keyText) == "" {
+		return fmt.Errorf("sign: MINISIGN_PRIVATE_KEY is empty")
+	}
+	priv, err := minisign.DecryptKey(os.Getenv("MINISIGN_PASSWORD"), []byte(keyText))
+	if err != nil {
+		return fmt.Errorf("sign: decrypt private key: %w", err)
+	}
+	for _, f := range files {
+		data, err := os.ReadFile(f)
+		if err != nil {
+			return err
+		}
+		sig := minisign.SignWithComments(priv, data,
+			"file:"+filepath.Base(f), "Reasonix desktop release")
+		out := f + ".minisig"
+		if err := os.WriteFile(out, sig, 0o644); err != nil {
+			return err
+		}
+		fmt.Printf("signed %s -> %s\n", f, out)
+	}
+	return nil
+}
+
+// genManifest scans dir for the per-platform artifacts and writes dir/latest.json.
+// version is the semver compared by the updater (e.g. "v1.1.0"); tag is the GitHub
+// release tag used in download URLs (e.g. "desktop-v1.1.0").
+func genManifest(dir, version, tag string) error {
+	repo := os.Getenv("GITHUB_REPOSITORY")
+	if repo == "" || repo == "esengine/reasonix" {
+		repo = "esengine/DeepSeek-Reasonix"
+	}
+	m := update.Manifest{
+		Version:      version,
+		DownloadPage: "https://reasonix.io/#start",
+		Platforms:    map[string]update.Asset{},
+	}
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return err
+	}
+	for _, e := range entries {
+		name := e.Name()
+		if e.IsDir() || strings.HasSuffix(name, ".minisig") || name == "latest.json" {
+			continue
+		}
+		key := matchPlatform(name)
+		if key == "" {
+			continue
+		}
+		size, sum, err := hashFile(filepath.Join(dir, name))
+		if err != nil {
+			return err
+		}
+		url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
+		m.Platforms[key] = update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
+		fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
+	}
+	if len(m.Platforms) == 0 {
+		return fmt.Errorf("manifest: no platform artifacts found in %s", dir)
+	}
+	b, err := json.MarshalIndent(m, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
+}
+
+// matchPlatform returns the platform key embedded in a file name, or "" if none.
+func matchPlatform(name string) string {
+	// The .deb is a human-download package (like the macOS .dmg); the Linux updater
+	// channel is the .tar.gz. Skip it so it doesn't shadow the tarball's linux-amd64 key.
+	if strings.HasSuffix(name, ".deb") {
+		return ""
+	}
+	// The Windows updater channel is the per-arch -installer.exe; the portable .zip
+	// is a human download, so skip it or it would shadow the installer's key.
+	if strings.Contains(name, "windows-") && !strings.HasSuffix(name, "-installer.exe") {
+		return ""
+	}
+	for _, p := range platforms {
+		if strings.Contains(name, p) {
+			return p
+		}
+	}
+	return ""
+}
+
+// hashFile returns the size and lowercase-hex SHA-256 of a file, streaming it so
+// large artifacts don't have to fit in memory.
+func hashFile(path string) (int64, string, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return 0, "", err
+	}
+	defer f.Close()
+	h := sha256.New()
+	n, err := io.Copy(h, f)
+	if err != nil {
+		return 0, "", err
+	}
+	return n, hex.EncodeToString(h.Sum(nil)), nil
+}
diff --git a/desktop/cmd/sign/main_test.go b/desktop/cmd/sign/main_test.go
new file mode 100644
index 0000000..f47a748
--- /dev/null
+++ b/desktop/cmd/sign/main_test.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+	"crypto/rand"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"aead.dev/minisign"
+
+	"reasonix/desktop/internal/update"
+)
+
+// TestSignFiles signs a file with a throwaway key pair (injected via env, exactly
+// as CI passes the real key) and verifies the produced .minisig validates under the
+// matching public key.
+func TestSignFiles(t *testing.T) {
+	pub, priv, err := minisign.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	enc, err := minisign.EncryptKey("pw", priv)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("MINISIGN_PRIVATE_KEY", string(enc))
+	t.Setenv("MINISIGN_PASSWORD", "pw")
+
+	dir := t.TempDir()
+	artifact := filepath.Join(dir, "Reasonix-linux-amd64.tar.gz")
+	payload := []byte("pretend this is a release tarball")
+	if err := os.WriteFile(artifact, payload, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := signFiles([]string{artifact}); err != nil {
+		t.Fatalf("signFiles: %v", err)
+	}
+	sig, err := os.ReadFile(artifact + ".minisig")
+	if err != nil {
+		t.Fatalf("read signature: %v", err)
+	}
+	if !minisign.Verify(pub, payload, sig) {
+		t.Fatal("produced signature does not verify under the signing key")
+	}
+}
+
+// TestGenManifest builds a manifest from a directory of fake artifacts and checks
+// every platform is listed with a download URL, a parallel .minisig URL, and a
+// non-empty digest. The .minisig and latest.json files must be ignored.
+func TestGenManifest(t *testing.T) {
+	dir := t.TempDir()
+	names := []string{
+		"Reasonix-darwin-arm64.zip",
+		"Reasonix-darwin-amd64.zip",
+		"Reasonix-windows-amd64-installer.exe",
+		"Reasonix-windows-amd64.zip", // portable download, not the updater channel
+		"Reasonix-windows-arm64-installer.exe",
+		"Reasonix-windows-arm64.zip", // portable download, not the updater channel
+		"Reasonix-linux-amd64.tar.gz",
+		"Reasonix-linux-amd64.deb",            // human download, not the updater channel
+		"Reasonix-linux-amd64.tar.gz.minisig", // must be skipped
+		"README.txt",                          // unmatched, must be skipped
+	}
+	for _, n := range names {
+		if err := os.WriteFile(filepath.Join(dir, n), []byte(n), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	t.Setenv("GITHUB_REPOSITORY", "esengine/reasonix")
+
+	if err := genManifest(dir, "v1.2.0", "desktop-v1.2.0"); err != nil {
+		t.Fatalf("genManifest: %v", err)
+	}
+	raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var m update.Manifest
+	if err := json.Unmarshal(raw, &m); err != nil {
+		t.Fatalf("latest.json is not valid: %v", err)
+	}
+	if m.Version != "v1.2.0" {
+		t.Fatalf("version = %q, want v1.2.0", m.Version)
+	}
+	if m.DownloadPage != "https://reasonix.io/#start" {
+		t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
+	}
+	if len(m.Platforms) != 5 {
+		t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
+	}
+	win, ok := m.Platforms["windows-amd64"]
+	if !ok {
+		t.Fatal("windows-amd64 missing")
+	}
+	wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
+	if win.URL != wantURL {
+		t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
+	}
+	if win.Sig != wantURL+".minisig" {
+		t.Fatalf("windows sig = %q, want %q.minisig", win.Sig, wantURL)
+	}
+	if win.SHA256 == "" || win.Size == 0 {
+		t.Fatalf("windows asset missing digest/size: %+v", win)
+	}
+	// The Windows updater channel is the per-arch -installer.exe; the portable .zip
+	// must not shadow the windows-arm64 key.
+	arm, ok := m.Platforms["windows-arm64"]
+	if !ok {
+		t.Fatal("windows-arm64 missing")
+	}
+	if !strings.HasSuffix(arm.URL, "/Reasonix-windows-arm64-installer.exe") {
+		t.Fatalf("windows-arm64 url = %q, want the installer, not the portable zip", arm.URL)
+	}
+	// The Linux updater channel must stay the .tar.gz; the co-located .deb is a
+	// human download and must not shadow the linux-amd64 key.
+	lin, ok := m.Platforms["linux-amd64"]
+	if !ok {
+		t.Fatal("linux-amd64 missing")
+	}
+	if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") {
+		t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL)
+	}
+}
diff --git a/desktop/cmd/update-helper/args.go b/desktop/cmd/update-helper/args.go
new file mode 100644
index 0000000..3423270
--- /dev/null
+++ b/desktop/cmd/update-helper/args.go
@@ -0,0 +1,11 @@
+package main
+
+import "fmt"
+
+func installerCommandLine(installer, dir string) string {
+	line := fmt.Sprintf(`"%s" /S`, installer)
+	if dir != "" {
+		line += " /D=" + dir
+	}
+	return line
+}
diff --git a/desktop/cmd/update-helper/args_test.go b/desktop/cmd/update-helper/args_test.go
new file mode 100644
index 0000000..affbff2
--- /dev/null
+++ b/desktop/cmd/update-helper/args_test.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
+	got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
+	want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
+	if got != want {
+		t.Fatalf("installerCommandLine = %q, want %q", got, want)
+	}
+	if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
+		t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
+	}
+}
diff --git a/desktop/cmd/update-helper/main_other.go b/desktop/cmd/update-helper/main_other.go
new file mode 100644
index 0000000..3ba00b8
--- /dev/null
+++ b/desktop/cmd/update-helper/main_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func main() {
+	fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
+	os.Exit(2)
+}
diff --git a/desktop/cmd/update-helper/main_windows.go b/desktop/cmd/update-helper/main_windows.go
new file mode 100644
index 0000000..13d819d
--- /dev/null
+++ b/desktop/cmd/update-helper/main_windows.go
@@ -0,0 +1,108 @@
+//go:build windows
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"syscall"
+	"time"
+
+	"golang.org/x/sys/windows"
+)
+
+const parentExitTimeout = 2 * time.Minute
+
+func main() {
+	os.Exit(run(os.Args[1:]))
+}
+
+func run(args []string) int {
+	var parentPID uint
+	var installer, installDir, relaunch string
+	fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
+	fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
+	fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
+	fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
+	if err := fs.Parse(args); err != nil {
+		return 2
+	}
+	logger := newLogger()
+	if installer == "" {
+		logger.Print("missing --installer")
+		return 2
+	}
+	if parentPID != 0 {
+		if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
+			logger.Printf("wait for parent process %d: %v", parentPID, err)
+			return 1
+		}
+	}
+	if err := runInstaller(installer, installDir); err != nil {
+		logger.Printf("run installer: %v", err)
+		return 1
+	}
+	if relaunch != "" {
+		if err := startRelaunch(relaunch, installDir); err != nil {
+			logger.Printf("relaunch: %v", err)
+			return 1
+		}
+	}
+	return 0
+}
+
+func newLogger() *log.Logger {
+	dir, err := os.UserCacheDir()
+	if err == nil {
+		dir = filepath.Join(dir, "Reasonix", "updates")
+		if err := os.MkdirAll(dir, 0o700); err == nil {
+			if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
+				return log.New(f, "", log.LstdFlags)
+			}
+		}
+	}
+	return log.New(os.Stderr, "", log.LstdFlags)
+}
+
+func waitForProcessExit(pid uint32, timeout time.Duration) error {
+	h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
+	if err != nil {
+		if err == windows.ERROR_INVALID_PARAMETER {
+			return nil
+		}
+		return err
+	}
+	defer windows.CloseHandle(h)
+	waitMS := uint32(timeout / time.Millisecond)
+	result, err := windows.WaitForSingleObject(h, waitMS)
+	if err != nil {
+		return err
+	}
+	switch result {
+	case windows.WAIT_OBJECT_0:
+		return nil
+	case uint32(windows.WAIT_TIMEOUT):
+		return fmt.Errorf("timed out after %s", timeout)
+	default:
+		return fmt.Errorf("unexpected wait result %d", result)
+	}
+}
+
+func runInstaller(installer, installDir string) error {
+	cmd := exec.Command(installer)
+	cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
+	return cmd.Run()
+}
+
+func startRelaunch(relaunch, installDir string) error {
+	cmd := exec.Command(relaunch)
+	cmd.Dir = installDir
+	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+	return cmd.Start()
+}
diff --git a/desktop/crash_app.go b/desktop/crash_app.go
new file mode 100644
index 0000000..2fb1728
--- /dev/null
+++ b/desktop/crash_app.go
@@ -0,0 +1,275 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"regexp"
+	"runtime"
+	"strings"
+)
+
+// crash_app.go is the crash/feedback/performance reporting surface. Reports are
+// sent only on an explicit user click in the frontend UI — never automatically.
+
+var crashEndpoint = "https://crash.reasonix.io/v1/report"
+
+const maxCrashDetailBytes = 16 << 10
+const maxCrashStackBytes = 8 << 10
+const maxCrashFieldBytes = 4 << 10
+const maxCrashBreadcrumbs = 30
+
+var (
+	userPathSegment       = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`)
+	emailPattern          = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`)
+	secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`)
+	bearerTokenPattern    = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`)
+	explicitKeyPattern    = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`)
+	envIdentifierPattern  = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`)
+	jwtPattern            = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
+	longHexPattern        = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`)
+	longBase64Pattern     = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`)
+	longBase64URLPattern  = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`)
+)
+
+func scrubUserPaths(s string) string {
+	return userPathSegment.ReplaceAllString(s, "${1}_")
+}
+
+func scrubSensitiveText(s string) string {
+	s = scrubUserPaths(s)
+	s = emailPattern.ReplaceAllString(s, "[redacted-email]")
+	s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]")
+	s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]")
+	s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]")
+	s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]")
+	s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]")
+	s = longHexPattern.ReplaceAllString(s, "[redacted-hex]")
+	s = longBase64Pattern.ReplaceAllString(s, "[redacted-token]")
+	s = longBase64URLPattern.ReplaceAllString(s, "[redacted-token]")
+	return s
+}
+
+type crashBreadcrumb struct {
+	T   int64  `json:"t,omitempty"`
+	Cat string `json:"cat,omitempty"`
+	Msg string `json:"msg,omitempty"`
+}
+
+type crashReport struct {
+	Kind           string            `json:"kind"`
+	Version        string            `json:"version"`
+	OS             string            `json:"os"`
+	Arch           string            `json:"arch"`
+	Message        string            `json:"message"`
+	Device         deviceInfo        `json:"device"`
+	SchemaVersion  int               `json:"schemaVersion,omitempty"`
+	Source         string            `json:"source,omitempty"`
+	Label          string            `json:"label,omitempty"`
+	ErrorType      string            `json:"errorType,omitempty"`
+	ErrorMessage   string            `json:"errorMessage,omitempty"`
+	Stack          string            `json:"stack,omitempty"`
+	ComponentStack string            `json:"componentStack,omitempty"`
+	TopFrame       string            `json:"topFrame,omitempty"`
+	BuildCommit    string            `json:"buildCommit,omitempty"`
+	Channel        string            `json:"channel,omitempty"`
+	Language       string            `json:"language,omitempty"`
+	View           string            `json:"view,omitempty"`
+	Breadcrumbs    []crashBreadcrumb `json:"breadcrumbs,omitempty"`
+	OccurredAt     string            `json:"occurredAt,omitempty"`
+}
+
+type frontendCrashPayload struct {
+	SchemaVersion  int               `json:"schemaVersion"`
+	Kind           string            `json:"kind"`
+	Source         string            `json:"source"`
+	Label          string            `json:"label"`
+	Message        string            `json:"message"`
+	ErrorType      string            `json:"errorType"`
+	ErrorMessage   string            `json:"errorMessage"`
+	Stack          string            `json:"stack"`
+	ComponentStack string            `json:"componentStack"`
+	TopFrame       string            `json:"topFrame"`
+	BuildCommit    string            `json:"buildCommit"`
+	Channel        string            `json:"channel"`
+	Language       string            `json:"language"`
+	View           string            `json:"view"`
+	Breadcrumbs    []crashBreadcrumb `json:"breadcrumbs"`
+	OccurredAt     string            `json:"occurredAt"`
+}
+
+func normalizeReportKind(kind string) (string, bool) {
+	switch strings.TrimSpace(kind) {
+	case "crash", "exception", "feedback", "performance", "bot":
+		return strings.TrimSpace(kind), true
+	default:
+		return "", false
+	}
+}
+
+func clipCrashField(s string, max int) string {
+	if len(s) > max {
+		return s[:max]
+	}
+	return s
+}
+
+func sanitizeCrashField(s string, max int) string {
+	return clipCrashField(scrubUserPaths(strings.TrimSpace(s)), max)
+}
+
+func sanitizeCrashText(s string, max int) string {
+	return clipCrashField(strings.TrimSpace(scrubSensitiveText(s)), max)
+}
+
+func sanitizeBreadcrumbs(in []crashBreadcrumb) []crashBreadcrumb {
+	if len(in) > maxCrashBreadcrumbs {
+		in = in[len(in)-maxCrashBreadcrumbs:]
+	}
+	out := make([]crashBreadcrumb, 0, len(in))
+	for _, b := range in {
+		cat := sanitizeCrashField(b.Cat, 64)
+		msg := sanitizeCrashText(b.Msg, 240)
+		if cat == "" && msg == "" {
+			continue
+		}
+		out = append(out, crashBreadcrumb{T: b.T, Cat: cat, Msg: msg})
+	}
+	return out
+}
+
+func baseCrashReport(kind string) crashReport {
+	return crashReport{
+		Kind:    kind,
+		Version: version,
+		OS:      runtime.GOOS,
+		Arch:    runtime.GOARCH,
+		Device:  collectDeviceInfo(),
+		Channel: channel,
+	}
+}
+
+func topFrameFromStack(stack string) string {
+	for _, line := range strings.Split(stack, "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		if strings.Contains(line, ".go:") || strings.Contains(line, ".ts:") || strings.Contains(line, ".tsx:") || strings.Contains(line, ".js:") || strings.Contains(line, ".jsx:") {
+			if strings.Contains(line, "/runtime/") || strings.Contains(line, `\runtime\`) || strings.Contains(line, "crash_pending.go") {
+				continue
+			}
+			return sanitizeCrashText(line, 300)
+		}
+	}
+	return ""
+}
+
+func nativeResourceContext() string {
+	var m runtime.MemStats
+	runtime.ReadMemStats(&m)
+	mb := func(n uint64) string {
+		return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
+	}
+	return strings.Join([]string{
+		"go heap alloc: " + mb(m.Alloc),
+		"go heap sys: " + mb(m.HeapSys),
+		"go total sys: " + mb(m.Sys),
+		fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()),
+		fmt.Sprintf("gc cycles: %d", m.NumGC),
+	}, "\n")
+}
+
+func appendNativeResourceContext(kind, message string) string {
+	if kind != "performance" {
+		return message
+	}
+	return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes)
+}
+
+func crashReportFromDetail(kind, detail string) (crashReport, error) {
+	rawKind := kind
+	kind, ok := normalizeReportKind(kind)
+	if !ok {
+		return crashReport{}, fmt.Errorf("unknown report kind %q", rawKind)
+	}
+	if strings.TrimSpace(detail) == "" {
+		return crashReport{}, fmt.Errorf("empty report")
+	}
+	r := baseCrashReport(kind)
+
+	var payload frontendCrashPayload
+	if json.Unmarshal([]byte(detail), &payload) == nil && payload.SchemaVersion == 2 {
+		if payloadKind, ok := normalizeReportKind(payload.Kind); ok {
+			r.Kind = payloadKind
+		}
+		r.SchemaVersion = payload.SchemaVersion
+		r.Source = sanitizeCrashField(payload.Source, 32)
+		r.Label = sanitizeCrashField(payload.Label, 64)
+		r.ErrorType = sanitizeCrashField(payload.ErrorType, 128)
+		r.ErrorMessage = sanitizeCrashText(payload.ErrorMessage, maxCrashFieldBytes)
+		r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes)
+		r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes)
+		r.TopFrame = sanitizeCrashText(payload.TopFrame, 300)
+		r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64)
+		r.Channel = sanitizeCrashField(payload.Channel, 32)
+		r.Language = sanitizeCrashField(payload.Language, 64)
+		r.View = sanitizeCrashText(payload.View, 200)
+		r.Breadcrumbs = sanitizeBreadcrumbs(payload.Breadcrumbs)
+		r.OccurredAt = sanitizeCrashField(payload.OccurredAt, 64)
+		r.Message = sanitizeCrashText(payload.Message, maxCrashDetailBytes)
+		if r.TopFrame == "" {
+			r.TopFrame = topFrameFromStack(r.Stack)
+		}
+		if r.Message == "" {
+			r.Message = sanitizeCrashText(fmt.Sprintf("[%s]\n\n%s", r.Label, r.ErrorMessage), maxCrashDetailBytes)
+		}
+		if r.Source == "" {
+			r.Source = "frontend"
+		}
+		r.Message = appendNativeResourceContext(r.Kind, r.Message)
+		return r, nil
+	}
+
+	r.SchemaVersion = 1
+	r.Source = "legacy"
+	r.Label = kind
+	r.Message = sanitizeCrashText(detail, maxCrashDetailBytes)
+	r.Message = appendNativeResourceContext(r.Kind, r.Message)
+	return r, nil
+}
+
+func (a *App) ReportCrash(kind, detail string) error {
+	r, err := crashReportFromDetail(kind, detail)
+	if err != nil {
+		return err
+	}
+	c, err := httpClient()
+	if err != nil {
+		return err
+	}
+	return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
+}
+
+func postCrashReport(ctx context.Context, c *http.Client, endpoint string, r crashReport) error {
+	body, err := json.Marshal(r)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := c.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 300 {
+		return fmt.Errorf("crash endpoint returned %s", resp.Status)
+	}
+	return nil
+}
diff --git a/desktop/crash_app_test.go b/desktop/crash_app_test.go
new file mode 100644
index 0000000..587b4fb
--- /dev/null
+++ b/desktop/crash_app_test.go
@@ -0,0 +1,207 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestScrubUserPaths(t *testing.T) {
+	cases := map[string]string{
+		`at C:\Users\yuhua\proj\app.ts:12:3`:      `at C:\Users\_\proj\app.ts:12:3`,
+		`at c:\users\someone\x.go`:                `at c:\users\_\x.go`,
+		`/home/bob/.reasonix/config.toml`:         `/home/_/.reasonix/config.toml`,
+		`/Users/alice/Library/Logs`:               `/Users/_/Library/Logs`,
+		`Error: ENOENT open '/home/bob/secret'`:   `Error: ENOENT open '/home/_/secret'`,
+		`no user path here: /usr/lib/node`:        `no user path here: /usr/lib/node`,
+		"first /home/a/x\nsecond C:\\Users\\b\\y": "first /home/_/x\nsecond C:\\Users\\_\\y",
+	}
+	for in, want := range cases {
+		if got := scrubUserPaths(in); got != want {
+			t.Errorf("scrubUserPaths(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestScrubSensitiveText(t *testing.T) {
+	apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
+	bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
+	longHex := "0123456789abcdef0123456789abcdef"
+	jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature"
+	got := scrubSensitiveText("user dev@example.com Authorization: Bearer " + bearer + " api_key=" + apiKey + " jwt " + jwt + " hash " + longHex + " env FEISHU_BOT_APP_SECRET WEIXIN_BOT_TOKEN short abc1234 path /Users/alice/x")
+
+	for _, leaked := range []string{"dev@example.com", bearer, apiKey, jwt, longHex, "FEISHU_BOT_APP_SECRET", "WEIXIN_BOT_TOKEN", "alice"} {
+		if strings.Contains(got, leaked) {
+			t.Fatalf("sensitive text leaked %q in %q", leaked, got)
+		}
+	}
+	for _, want := range []string{"[redacted-email]", "Authorization=[redacted]", "api_key=[redacted]", "[redacted-jwt]", "[redacted-hex]", "[redacted-env]", "short abc1234", "/Users/_/x"} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("scrubSensitiveText() = %q, want it to contain %q", got, want)
+		}
+	}
+}
+
+func TestPostCrashReport(t *testing.T) {
+	var got crashReport
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			t.Errorf("method = %s, want POST", r.Method)
+		}
+		if ct := r.Header.Get("Content-Type"); ct != "application/json" {
+			t.Errorf("content-type = %q", ct)
+		}
+		body, _ := io.ReadAll(r.Body)
+		if err := json.Unmarshal(body, &got); err != nil {
+			t.Errorf("body not JSON: %v", err)
+		}
+		w.WriteHeader(http.StatusAccepted)
+	}))
+	defer srv.Close()
+
+	r := crashReport{Kind: "crash", Version: "v9.9.9", OS: "windows", Arch: "amd64", Message: "[react]\nboom"}
+	if err := postCrashReport(context.Background(), srv.Client(), srv.URL, r); err != nil {
+		t.Fatal(err)
+	}
+	if !reflect.DeepEqual(got, r) {
+		t.Errorf("server received %+v, want %+v", got, r)
+	}
+}
+
+func TestPostCrashReportRejectedStatus(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusTooManyRequests)
+	}))
+	defer srv.Close()
+
+	err := postCrashReport(context.Background(), srv.Client(), srv.URL, crashReport{Kind: "crash"})
+	if err == nil || !strings.Contains(err.Error(), "429") {
+		t.Fatalf("want 429 error, got %v", err)
+	}
+}
+
+func TestReportCrashRejectsBadInput(t *testing.T) {
+	app := NewApp()
+	if err := app.ReportCrash("telemetry", "x"); err == nil {
+		t.Error("unknown kind should be rejected")
+	}
+	if err := app.ReportCrash("crash", ""); err == nil {
+		t.Error("empty detail should be rejected")
+	}
+}
+
+func TestCrashReportFromStructuredDetail(t *testing.T) {
+	apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
+	secretHex := "abcdefabcdefabcdefabcdefabcdef12"
+	buildCommit := "0123456789abcdef0123456789abcdef01234567"
+	payload := frontendCrashPayload{
+		SchemaVersion: 2,
+		Kind:          "exception",
+		Source:        "frontend",
+		Label:         "unhandledrejection",
+		Message:       "[unhandledrejection]\n\ninvalid argument at C:\\Users\\alice\\app.ts:1 from alice@example.com",
+		ErrorType:     "TypeError",
+		ErrorMessage:  "invalid argument at /Users/alice/project/app.ts api_key=" + apiKey,
+		Stack:         "TypeError: invalid argument\n    at run (/Users/alice/project/app.ts:12:3)\nsecret=" + secretHex,
+		TopFrame:      "at run (/Users/alice/project/app.ts:12:3)",
+		BuildCommit:   buildCommit,
+		Channel:       "canary",
+		Language:      "zh-CN",
+		View:          "wails://wails.localhost/index.html?token=" + secretHex,
+		Breadcrumbs:   []crashBreadcrumb{{T: 1, Cat: "bridge", Msg: "turn SubmitToTab token=" + apiKey}},
+	}
+	detail, err := json.Marshal(payload)
+	if err != nil {
+		t.Fatal(err)
+	}
+	r, err := crashReportFromDetail("crash", string(detail))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if r.Kind != "exception" || r.Source != "frontend" || r.Label != "unhandledrejection" {
+		t.Fatalf("structured fields not preserved: %+v", r)
+	}
+	if strings.Contains(r.Message, "alice") || strings.Contains(r.ErrorMessage, "alice") || strings.Contains(r.Stack, "alice") {
+		t.Fatalf("user path was not scrubbed: %+v", r)
+	}
+	if r.TopFrame == "" || r.BuildCommit != buildCommit || r.Channel != "canary" || len(r.Breadcrumbs) != 1 {
+		t.Fatalf("metadata missing: %+v", r)
+	}
+	freeText := strings.Join([]string{
+		r.Message,
+		r.ErrorMessage,
+		r.Stack,
+		r.ComponentStack,
+		r.TopFrame,
+		r.View,
+		r.Breadcrumbs[0].Msg,
+	}, "\n")
+	for _, leaked := range []string{apiKey, secretHex, "alice@example.com"} {
+		if strings.Contains(freeText, leaked) {
+			t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
+		}
+	}
+}
+
+func TestCrashReportFromPerformanceDetail(t *testing.T) {
+	payload := frontendCrashPayload{
+		SchemaVersion: 2,
+		Kind:          "performance",
+		Source:        "frontend.performance",
+		Label:         "performance.pressure",
+		Message:       "[performance.pressure]\n\n--- performance context ---\nreason: event loop lag 1300ms",
+		ErrorType:     "PerformancePressure",
+		ErrorMessage:  "UI responsiveness degraded because the app observed long tasks, event-loop lag, or high JS heap pressure.",
+		TopFrame:      "frontend.performance",
+	}
+	detail, err := json.Marshal(payload)
+	if err != nil {
+		t.Fatal(err)
+	}
+	r, err := crashReportFromDetail("performance", string(detail))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if r.Kind != "performance" || r.Source != "frontend.performance" || r.Label != "performance.pressure" {
+		t.Fatalf("performance fields not preserved: %+v", r)
+	}
+	if !strings.Contains(r.Message, "--- native runtime context ---") || !strings.Contains(r.Message, "goroutines:") {
+		t.Fatalf("native runtime context missing from performance report: %q", r.Message)
+	}
+}
+
+func TestCrashReportFromBotDetail(t *testing.T) {
+	token := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
+	payload := frontendCrashPayload{
+		SchemaVersion: 2,
+		Kind:          "bot",
+		Source:        "bot.runtime",
+		Label:         "bot.feishu.lark.send",
+		Message:       "[bot]\n\nfailed at /Users/alice/project with token=" + token,
+		ErrorType:     "BotConnectionDiagnostic",
+		ErrorMessage:  "send failed with Bearer " + token,
+		TopFrame:      "bot.send",
+	}
+	detail, err := json.Marshal(payload)
+	if err != nil {
+		t.Fatal(err)
+	}
+	r, err := crashReportFromDetail("bot", string(detail))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if r.Kind != "bot" || r.Source != "bot.runtime" || r.Label != "bot.feishu.lark.send" {
+		t.Fatalf("bot fields not preserved: %+v", r)
+	}
+	if strings.Contains(r.Message, "alice") || strings.Contains(r.Message, token) || strings.Contains(r.ErrorMessage, token) {
+		t.Fatalf("bot report was not scrubbed: %+v", r)
+	}
+	if strings.Contains(r.Message, "--- native runtime context ---") {
+		t.Fatalf("bot report should not include performance runtime context: %q", r.Message)
+	}
+}
diff --git a/desktop/crash_pending.go b/desktop/crash_pending.go
new file mode 100644
index 0000000..90404a3
--- /dev/null
+++ b/desktop/crash_pending.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime/debug"
+
+	"reasonix/internal/config"
+)
+
+// crash_pending.go captures Go-side panics to disk and ships them on the next
+// launch. Frontend crashes are click-to-send, but an unrecovered Go panic kills the
+// process before the user can react, so the whole agent/provider/tool layer would
+// otherwise never surface a single report. The resend is gated on the same
+// desktop.telemetry opt-out as the launch ping.
+
+const pendingCrashFile = "crash-pending.json"
+
+func pendingCrashPath() string {
+	return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
+}
+
+// recoverToPending records a panicking goroutine to the pending-crash file and
+// re-raises, so the process still crashes exactly as before — the stack is now
+// shipped next launch instead of lost.
+func (a *App) recoverToPending(site string) {
+	r := recover()
+	if r == nil {
+		return
+	}
+	writePendingCrash(site, r, debug.Stack())
+	panic(r)
+}
+
+func writePendingCrash(site string, r any, stack []byte) {
+	stackText := string(stack)
+	msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s: %v\n\n%s", site, r, stackText), maxCrashDetailBytes)
+	report := baseCrashReport("crash")
+	report.SchemaVersion = 2
+	report.Source = "go"
+	report.Label = sanitizeCrashField(site, 64)
+	report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
+	report.ErrorMessage = sanitizeCrashText(fmt.Sprint(r), maxCrashFieldBytes)
+	report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
+	report.TopFrame = topFrameFromStack(report.Stack)
+	report.Message = msg
+	_ = writePendingReport(report, true)
+}
+
+func writePendingReport(report crashReport, overwrite bool) bool {
+	body, err := json.Marshal(report)
+	if err != nil {
+		return false
+	}
+	path := pendingCrashPath()
+	if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
+		return false
+	}
+	if overwrite {
+		return os.WriteFile(path, body, 0o644) == nil
+	}
+	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+	if err != nil {
+		return false
+	}
+	defer f.Close()
+	n, err := f.Write(body)
+	if err != nil || n != len(body) {
+		_ = os.Remove(path)
+		return false
+	}
+	return true
+}
+
+func (a *App) goSafe(site string, fn func()) {
+	go func() {
+		defer a.recoverToPending(site)
+		fn()
+	}()
+}
+
+// flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
+// clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
+// dropping the file unsent.
+func (a *App) flushPendingCrash() {
+	if version == "dev" {
+		return
+	}
+	path := pendingCrashPath()
+	body, err := readFileUTF8(path)
+	if err != nil {
+		return
+	}
+	cfg, err := config.Load()
+	if err != nil || !cfg.DesktopTelemetry() {
+		_ = os.Remove(path)
+		return
+	}
+	var r crashReport
+	if json.Unmarshal(body, &r) != nil {
+		_ = os.Remove(path)
+		return
+	}
+	c, err := httpClient()
+	if err != nil {
+		return
+	}
+	if postCrashReport(a.bootContext(), c, crashEndpoint, r) == nil {
+		_ = os.Remove(path)
+	}
+}
diff --git a/desktop/crash_pending_test.go b/desktop/crash_pending_test.go
new file mode 100644
index 0000000..ab6340f
--- /dev/null
+++ b/desktop/crash_pending_test.go
@@ -0,0 +1,191 @@
+package main
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+)
+
+func readPending(t *testing.T) (crashReport, bool) {
+	t.Helper()
+	body, err := os.ReadFile(pendingCrashPath())
+	if err != nil {
+		return crashReport{}, false
+	}
+	var r crashReport
+	if err := json.Unmarshal(body, &r); err != nil {
+		t.Fatalf("pending file not valid JSON: %v", err)
+	}
+	return r, true
+}
+
+func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
+	t.Cleanup(func() { os.Remove(pendingCrashPath()) })
+
+	func() {
+		defer func() {
+			if recover() == nil {
+				t.Fatal("recoverToPending must re-raise the panic")
+			}
+		}()
+		app := NewApp()
+		defer app.recoverToPending("unit")
+		panic(`boom at C:\Users\alice\proj\x.go`)
+	}()
+
+	r, ok := readPending(t)
+	if !ok {
+		t.Fatal("expected a pending crash file")
+	}
+	if r.Kind != "crash" {
+		t.Errorf("kind = %q, want crash", r.Kind)
+	}
+	if !strings.Contains(r.Message, "[go panic] unit:") {
+		t.Errorf("message missing site prefix: %q", r.Message)
+	}
+	if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
+		t.Errorf("structured panic metadata missing: %+v", r)
+	}
+	if strings.Contains(r.Message, `Users\alice`) {
+		t.Errorf("message not scrubbed: %q", r.Message)
+	}
+}
+
+func TestWritePendingCrashCaps(t *testing.T) {
+	t.Cleanup(func() { os.Remove(pendingCrashPath()) })
+	writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
+	r, ok := readPending(t)
+	if !ok {
+		t.Fatal("expected a pending crash file")
+	}
+	if len(r.Message) > maxCrashDetailBytes {
+		t.Errorf("message len = %d, want <= %d", len(r.Message), maxCrashDetailBytes)
+	}
+}
+
+func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
+	t.Cleanup(func() { os.Remove(pendingCrashPath()) })
+	writePendingCrash("panic", "boom", []byte("stack"))
+	before, ok := readPending(t)
+	if !ok {
+		t.Fatal("expected initial pending crash")
+	}
+
+	hang := baseCrashReport("performance")
+	hang.Source = "native.watchdog"
+	hang.Label = "mac.main_thread.hang"
+	hang.Message = "hang"
+	if writePendingReport(hang, false) {
+		t.Fatal("writePendingReport overwrite=false should not replace existing report")
+	}
+	after, ok := readPending(t)
+	if !ok {
+		t.Fatal("expected pending crash after skipped write")
+	}
+	if after.Label != before.Label || after.Message != before.Message {
+		t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
+	}
+}
+
+func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
+	t.Cleanup(func() { os.Remove(pendingCrashPath()) })
+	const writers = 32
+	start := make(chan struct{})
+	var ready sync.WaitGroup
+	var done sync.WaitGroup
+	var successes atomic.Int32
+
+	for i := 0; i < writers; i++ {
+		ready.Add(1)
+		done.Add(1)
+		go func() {
+			defer done.Done()
+			report := baseCrashReport("performance")
+			report.Source = "native.watchdog"
+			report.Label = "mac.main_thread.hang"
+			report.Message = strings.Repeat("hang", 1024)
+			ready.Done()
+			<-start
+			if writePendingReport(report, false) {
+				successes.Add(1)
+			}
+		}()
+	}
+	ready.Wait()
+	close(start)
+	done.Wait()
+
+	if got := successes.Load(); got != 1 {
+		t.Fatalf("successful non-overwrite writers = %d, want 1", got)
+	}
+	if _, ok := readPending(t); !ok {
+		t.Fatal("expected one pending report")
+	}
+}
+
+func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
+	t.Cleanup(func() { os.Remove(pendingCrashPath()) })
+	apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
+	bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
+	longHex := "0123456789abcdef0123456789abcdef"
+
+	writePendingCrash("unit", "boom api_key="+apiKey+" user alice@example.com", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
+	r, ok := readPending(t)
+	if !ok {
+		t.Fatal("expected a pending crash file")
+	}
+	freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
+	for _, leaked := range []string{apiKey, bearer, longHex, "alice@example.com", "/home/alice"} {
+		if strings.Contains(freeText, leaked) {
+			t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
+		}
+	}
+}
+
+func TestFlushPendingCrashSendsAndClears(t *testing.T) {
+	oldVersion, oldEndpoint := version, crashEndpoint
+	t.Cleanup(func() {
+		version, crashEndpoint = oldVersion, oldEndpoint
+		os.Remove(pendingCrashPath())
+	})
+	version = "v9.9.9"
+
+	var hits atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		hits.Add(1)
+		w.WriteHeader(http.StatusAccepted)
+	}))
+	defer srv.Close()
+	crashEndpoint = srv.URL
+
+	writePendingCrash("flush", "boom", []byte("stack"))
+	NewApp().flushPendingCrash()
+
+	if hits.Load() != 1 {
+		t.Errorf("server hits = %d, want 1", hits.Load())
+	}
+	if _, ok := readPending(t); ok {
+		t.Error("pending file should be cleared after a successful send")
+	}
+}
+
+func TestFlushPendingCrashDevGuard(t *testing.T) {
+	oldVersion := version
+	t.Cleanup(func() {
+		version = oldVersion
+		os.Remove(pendingCrashPath())
+	})
+	version = "dev"
+
+	writePendingCrash("dev", "boom", []byte("stack"))
+	NewApp().flushPendingCrash()
+
+	if _, ok := readPending(t); !ok {
+		t.Error("dev build must leave the pending file untouched")
+	}
+}
diff --git a/desktop/deferred_rebuild.go b/desktop/deferred_rebuild.go
new file mode 100644
index 0000000..83dea3a
--- /dev/null
+++ b/desktop/deferred_rebuild.go
@@ -0,0 +1,384 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/control"
+)
+
+// deferredRebuildRetryInterval is how often the retry loop probes a held
+// session lease. Package-level so tests can shorten it.
+var deferredRebuildRetryInterval = 2 * time.Second
+
+const deferredStartupBuildLabel = "__startup__"
+
+// deferredRebuildState tracks tabs whose settings were saved to disk but whose
+// runtime could not refresh, plus tabs whose initial startup failed, because
+// the session lease was held by another Reasonix process. A single background
+// loop probes the lease and replays the rebuild once the other side releases
+// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
+// hook); tests that never call it get the pending bookkeeping without a
+// background goroutine.
+type deferredRebuildState struct {
+	mu      sync.Mutex
+	pending map[string]string // tab ID -> setting label for notices
+	enabled bool
+	running bool
+	stopped bool
+	stop    chan struct{}
+}
+
+// enableDeferredRebuildRetry arms the retry loop; called from startup.
+func (a *App) enableDeferredRebuildRetry() {
+	d := &a.deferredRebuild
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	d.enabled = true
+	a.startDeferredRebuildLoopLocked()
+}
+
+// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
+// has work. Callers must hold d.mu.
+func (a *App) startDeferredRebuildLoopLocked() {
+	d := &a.deferredRebuild
+	if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
+		return
+	}
+	d.running = true
+	if d.stop == nil {
+		d.stop = make(chan struct{})
+	}
+	go a.deferredRebuildLoop(d.stop)
+}
+
+// scheduleDeferredRebuild records that tabID needs a runtime refresh for
+// setting and starts the retry loop if it is not running yet. Repeated calls
+// for the same tab collapse into one retry carrying the latest label.
+func (a *App) scheduleDeferredRebuild(tabID, setting string) {
+	tabID = strings.TrimSpace(tabID)
+	if tabID == "" {
+		return
+	}
+	d := &a.deferredRebuild
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	if d.stopped {
+		return
+	}
+	if d.pending == nil {
+		d.pending = map[string]string{}
+	}
+	d.pending[tabID] = setting
+	a.startDeferredRebuildLoopLocked()
+}
+
+func (a *App) scheduleDeferredStartupBuild(tabID string) {
+	a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
+}
+
+func isDeferredStartupBuild(setting string) bool {
+	return setting == deferredStartupBuildLabel
+}
+
+func (a *App) clearDeferredRebuild(tabID string) {
+	d := &a.deferredRebuild
+	d.mu.Lock()
+	delete(d.pending, tabID)
+	d.mu.Unlock()
+}
+
+func (a *App) deferredRebuildPending(tabID string) bool {
+	d := &a.deferredRebuild
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	_, ok := d.pending[tabID]
+	return ok
+}
+
+// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
+// and by tests.
+func (a *App) stopDeferredRebuildRetry() {
+	d := &a.deferredRebuild
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	if d.stopped {
+		return
+	}
+	d.stopped = true
+	if d.stop != nil {
+		close(d.stop)
+	}
+}
+
+func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
+	ticker := time.NewTicker(deferredRebuildRetryInterval)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-stop:
+			d := &a.deferredRebuild
+			d.mu.Lock()
+			d.running = false
+			d.mu.Unlock()
+			return
+		case <-ticker.C:
+		}
+		if a.deferredRebuildTickDone() {
+			return
+		}
+	}
+}
+
+// deferredRebuildTickDone runs one retry pass and reports true when the loop
+// should exit because nothing is pending anymore.
+func (a *App) deferredRebuildTickDone() bool {
+	return a.deferredRebuildTick(true)
+}
+
+func (a *App) deferredRebuildTick(markIdle bool) bool {
+	d := &a.deferredRebuild
+	d.mu.Lock()
+	if d.stopped || len(d.pending) == 0 {
+		if markIdle {
+			d.running = false
+		}
+		d.mu.Unlock()
+		return true
+	}
+	pending := make(map[string]string, len(d.pending))
+	for id, setting := range d.pending {
+		pending[id] = setting
+	}
+	d.mu.Unlock()
+
+	for tabID, setting := range pending {
+		a.retryDeferredRebuild(tabID, setting)
+	}
+	return false
+}
+
+func (a *App) kickDeferredRebuildRetry() {
+	if a.ctx == nil {
+		return
+	}
+	a.goSafe("deferredRebuildKick", func() {
+		_ = a.deferredRebuildTick(false)
+	})
+}
+
+func (a *App) retryDeferredRebuild(tabID, setting string) {
+	if a.ctx == nil {
+		return
+	}
+	tab := a.tabByID(tabID)
+	if tab == nil || tab.ID != tabID {
+		// The tab is gone; nothing left to refresh.
+		a.clearDeferredRebuild(tabID)
+		return
+	}
+	if isDeferredStartupBuild(setting) {
+		a.retryDeferredStartupBuild(tabID, tab)
+		return
+	}
+	// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
+	// the session lease, and a concurrent manual rebuild's ensureSessionLease
+	// would see that probe as "held by another runtime" and spuriously defer.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	// rebuildSettingLocked refreshes the active tab only. Wait until the user
+	// is back on this tab so we refresh the runtime the pending setting was
+	// meant for, not whichever tab happens to be focused.
+	if a.activeTab() != tab {
+		return
+	}
+	ctrl := a.controllerForTab(tab)
+	if ctrl == nil {
+		// Mid-(re)build on another path (provider retarget, workspace repair);
+		// racing a second build+swap against it is what this loop must avoid.
+		return
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return
+	}
+	if !a.deferredRebuildLeaseLooksFree(tab) {
+		return
+	}
+	err := a.rebuildSettingLocked(setting)
+	if err == nil {
+		// rebuildSettingLocked already cleared the pending entry for the tab it
+		// refreshed; just announce it.
+		a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
+		return
+	}
+	if errors.Is(err, agent.ErrSessionLeaseHeld) {
+		return // grabbed back before we could rebuild; keep waiting
+	}
+	var busy *rebuildBusyError
+	if errors.As(err, &busy) {
+		return // a turn started meanwhile; retry once it finishes
+	}
+	// Anything else will not resolve by waiting; give up loudly instead of
+	// retrying forever.
+	a.clearDeferredRebuild(tabID)
+	slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
+	a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
+}
+
+func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	if !a.tabHasRetryableStartupLeaseError(tab) {
+		a.clearDeferredRebuild(tabID)
+		return
+	}
+	if !a.deferredRebuildLeaseLooksFree(tab) {
+		return
+	}
+	err := a.rebuildStartupTabLocked(tab)
+	if err == nil {
+		a.clearDeferredRebuild(tabID)
+		return
+	}
+	if errors.Is(err, agent.ErrSessionLeaseHeld) {
+		return
+	}
+	a.clearDeferredRebuild(tabID)
+	slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
+}
+
+func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
+	if tab == nil {
+		return false
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
+}
+
+func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
+	buildCtx, cancel := context.WithCancel(a.bootContext())
+	a.mu.Lock()
+	if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
+		a.mu.Unlock()
+		cancel()
+		return nil
+	}
+	if tab.Ctrl != nil {
+		a.mu.Unlock()
+		cancel()
+		return nil
+	}
+	if !tab.StartupErrLeaseHeld {
+		a.mu.Unlock()
+		cancel()
+		return nil
+	}
+	tab.buildGeneration++
+	generation := tab.buildGeneration
+	if tab.buildCancel != nil {
+		tab.buildCancel()
+	}
+	tab.buildCancel = cancel
+	tab.Ready = false
+	clearTabStartupError(tab)
+	tab.ActivityStatus = ""
+	if tab.sink == nil {
+		tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+	}
+	a.saveTabsLocked()
+	a.mu.Unlock()
+
+	a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
+
+	a.mu.RLock()
+	stillCurrent := false
+	var ctrl control.SessionAPI
+	startupErr := ""
+	leaseHeld := false
+	if tab != nil {
+		stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
+		ctrl = tab.Ctrl
+		startupErr = tab.StartupErr
+		leaseHeld = tab.StartupErrLeaseHeld
+	}
+	a.mu.RUnlock()
+	if !stillCurrent || ctrl != nil {
+		return nil
+	}
+	if leaseHeld {
+		return agent.ErrSessionLeaseHeld
+	}
+	if strings.TrimSpace(startupErr) != "" {
+		return fmt.Errorf("session startup: %s", startupErr)
+	}
+	return fmt.Errorf("session startup: controller was not built")
+}
+
+func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
+	if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
+		return false
+	}
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	if !a.tabHasRetryableStartupLeaseError(tab) {
+		return a.controllerForTab(tab) != nil
+	}
+	if !a.deferredRebuildLeaseLooksFree(tab) {
+		return false
+	}
+	err := a.rebuildStartupTabLocked(tab)
+	if err == nil {
+		a.clearDeferredRebuild(tab.ID)
+		return a.controllerForTab(tab) != nil
+	}
+	if errors.Is(err, agent.ErrSessionLeaseHeld) {
+		a.scheduleDeferredStartupBuild(tab.ID)
+	} else {
+		a.clearDeferredRebuild(tab.ID)
+	}
+	return false
+}
+
+// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
+// could be acquired right now, without touching tab.sessionLease (only the
+// serialized rebuild paths may mutate that). The probe path can lag the
+// reconciled path the rebuild will use; a stale answer either re-defers on the
+// next tick or lets the rebuild fail back into the pending set, so a mismatch
+// only delays the retry.
+func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
+	a.mu.RLock()
+	ctrl := tab.Ctrl
+	path := strings.TrimSpace(tab.SessionPath)
+	a.mu.RUnlock()
+	if ctrl != nil {
+		if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
+			path = p
+		}
+	}
+	if path == "" {
+		return true // nothing to probe; let the rebuild decide
+	}
+	lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
+	if err != nil {
+		var leaseErr *agent.SessionLeaseError
+		if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
+			if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
+				// This process (usually this very tab) holds the probed path;
+				// the blocking lease is some other path. Attempt the rebuild
+				// and let its own lease checks decide.
+				return true
+			}
+		}
+		return !errors.Is(err, agent.ErrSessionLeaseHeld)
+	}
+	lease.Release()
+	return true
+}
diff --git a/desktop/devinfo.go b/desktop/devinfo.go
new file mode 100644
index 0000000..945967e
--- /dev/null
+++ b/desktop/devinfo.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+	"runtime"
+	"strconv"
+	"strings"
+)
+
+// devinfo.go collects coarse machine facts attached to crash reports: OS version,
+// CPU model, core count, RAM. Nothing here identifies a user or machine.
+
+type deviceInfo struct {
+	OSVersion string `json:"osVersion,omitempty"`
+	CPU       string `json:"cpu,omitempty"`
+	Cores     int    `json:"cores"`
+	RAMGB     int    `json:"ramGb,omitempty"`
+}
+
+const gib = 1 << 30
+
+func collectDeviceInfo() deviceInfo {
+	return deviceInfo{
+		OSVersion: platformOSVersion(),
+		CPU:       platformCPU(),
+		Cores:     runtime.NumCPU(),
+		RAMGB:     int((platformRAMBytes() + gib/2) / gib),
+	}
+}
+
+func parseCPUModel(cpuinfo string) string {
+	for _, line := range strings.Split(cpuinfo, "\n") {
+		if name, ok := strings.CutPrefix(line, "model name"); ok {
+			if _, v, ok := strings.Cut(name, ":"); ok {
+				return strings.TrimSpace(v)
+			}
+		}
+	}
+	return ""
+}
+
+func parseMemTotalBytes(meminfo string) uint64 {
+	for _, line := range strings.Split(meminfo, "\n") {
+		if rest, ok := strings.CutPrefix(line, "MemTotal:"); ok {
+			kb, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(rest), " kB"), 10, 64)
+			if err != nil {
+				return 0
+			}
+			return kb * 1024
+		}
+	}
+	return 0
+}
+
+func parseOSReleasePrettyName(osRelease string) string {
+	for _, line := range strings.Split(osRelease, "\n") {
+		if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
+			return strings.Trim(strings.TrimSpace(v), `"`)
+		}
+	}
+	return ""
+}
diff --git a/desktop/devinfo_darwin.go b/desktop/devinfo_darwin.go
new file mode 100644
index 0000000..9c3780f
--- /dev/null
+++ b/desktop/devinfo_darwin.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+func sysctlString(name string) string {
+	out, err := exec.Command("sysctl", "-n", name).Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
+
+func platformOSVersion() string {
+	out, err := exec.Command("sw_vers", "-productVersion").Output()
+	if err != nil {
+		return "macOS"
+	}
+	return "macOS " + strings.TrimSpace(string(out))
+}
+
+func platformCPU() string {
+	return sysctlString("machdep.cpu.brand_string")
+}
+
+func platformRAMBytes() uint64 {
+	n, err := strconv.ParseUint(sysctlString("hw.memsize"), 10, 64)
+	if err != nil {
+		return 0
+	}
+	return n
+}
diff --git a/desktop/devinfo_linux.go b/desktop/devinfo_linux.go
new file mode 100644
index 0000000..2111151
--- /dev/null
+++ b/desktop/devinfo_linux.go
@@ -0,0 +1,26 @@
+package main
+
+import "os"
+
+func readOr(path string) string {
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return ""
+	}
+	return string(b)
+}
+
+func platformOSVersion() string {
+	if name := parseOSReleasePrettyName(readOr("/etc/os-release")); name != "" {
+		return name
+	}
+	return "Linux"
+}
+
+func platformCPU() string {
+	return parseCPUModel(readOr("/proc/cpuinfo"))
+}
+
+func platformRAMBytes() uint64 {
+	return parseMemTotalBytes(readOr("/proc/meminfo"))
+}
diff --git a/desktop/devinfo_test.go b/desktop/devinfo_test.go
new file mode 100644
index 0000000..130782d
--- /dev/null
+++ b/desktop/devinfo_test.go
@@ -0,0 +1,40 @@
+package main
+
+import "testing"
+
+func TestParseCPUModel(t *testing.T) {
+	cpuinfo := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i7-12700K\nflags\t: fpu vme\n"
+	if got := parseCPUModel(cpuinfo); got != "Intel(R) Core(TM) i7-12700K" {
+		t.Errorf("parseCPUModel = %q", got)
+	}
+	if got := parseCPUModel("no such field"); got != "" {
+		t.Errorf("parseCPUModel on garbage = %q, want empty", got)
+	}
+}
+
+func TestParseMemTotalBytes(t *testing.T) {
+	meminfo := "MemTotal:       32652284 kB\nMemFree:         1234 kB\n"
+	if got := parseMemTotalBytes(meminfo); got != 32652284*1024 {
+		t.Errorf("parseMemTotalBytes = %d", got)
+	}
+	if got := parseMemTotalBytes("MemFree: 1 kB"); got != 0 {
+		t.Errorf("parseMemTotalBytes without MemTotal = %d, want 0", got)
+	}
+}
+
+func TestParseOSReleasePrettyName(t *testing.T) {
+	osRelease := "NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 24.04.1 LTS\"\nID=ubuntu\n"
+	if got := parseOSReleasePrettyName(osRelease); got != "Ubuntu 24.04.1 LTS" {
+		t.Errorf("parseOSReleasePrettyName = %q", got)
+	}
+}
+
+func TestCollectDeviceInfoSane(t *testing.T) {
+	d := collectDeviceInfo()
+	if d.Cores < 1 {
+		t.Errorf("Cores = %d", d.Cores)
+	}
+	if d.OSVersion == "" {
+		t.Error("OSVersion empty")
+	}
+}
diff --git a/desktop/devinfo_windows.go b/desktop/devinfo_windows.go
new file mode 100644
index 0000000..5ce4a3b
--- /dev/null
+++ b/desktop/devinfo_windows.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"fmt"
+	"strings"
+	"unsafe"
+
+	"golang.org/x/sys/windows"
+	"golang.org/x/sys/windows/registry"
+)
+
+func platformOSVersion() string {
+	v := windows.RtlGetVersion()
+	return fmt.Sprintf("Windows %d.%d build %d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
+}
+
+func platformCPU() string {
+	k, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
+	if err != nil {
+		return ""
+	}
+	defer k.Close()
+	name, _, err := k.GetStringValue("ProcessorNameString")
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(name)
+}
+
+func platformRAMBytes() uint64 {
+	var kb uint64
+	proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetPhysicallyInstalledSystemMemory")
+	if ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&kb))); ret == 0 {
+		return 0
+	}
+	return kb * 1024
+}
diff --git a/desktop/dotenv.go b/desktop/dotenv.go
new file mode 100644
index 0000000..a09371e
--- /dev/null
+++ b/desktop/dotenv.go
@@ -0,0 +1,133 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"reasonix/internal/config"
+	"reasonix/internal/fileutil"
+)
+
+// upsertDotEnv stores KEY=value in Reasonix's global .env and applies it to the
+// running process so a rebuild picks it up without a restart.
+func upsertDotEnv(key, value string) error {
+	_, err := config.SetCredential(key, value)
+	return err
+}
+
+func removeDotEnv(key string) error {
+	return config.RemoveCredential(key)
+}
+
+// upsertEnvFile merges KEY=value into a KEY=value file at path, preserving
+// comments and unrelated lines, writing atomically via a sibling temp + rename.
+func upsertEnvFile(path, key, value string) error {
+	key = strings.TrimSpace(key)
+	if key == "" {
+		return nil
+	}
+	var lines []string
+	if b, err := readFileUTF8(path); err == nil {
+		lines = strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+	}
+	replaced := false
+	for i, ln := range lines {
+		t := strings.TrimSpace(ln)
+		if t == "" || strings.HasPrefix(t, "#") {
+			continue
+		}
+		if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
+			lines[i] = key + "=" + value
+			replaced = true
+			break
+		}
+	}
+	if !replaced {
+		lines = append(lines, key+"="+value)
+	}
+	out := strings.Join(lines, "\n") + "\n"
+
+	dir := filepath.Dir(path)
+	if dir != "" && dir != "." {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return err
+		}
+	}
+	tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
+	if err != nil {
+		return err
+	}
+	tmpPath := tmp.Name()
+	if _, err := tmp.WriteString(out); err != nil {
+		tmp.Close()
+		os.Remove(tmpPath)
+		return err
+	}
+	if err := tmp.Close(); err != nil {
+		os.Remove(tmpPath)
+		return err
+	}
+	if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
+		os.Remove(tmpPath)
+		return err
+	}
+	return os.Setenv(key, value)
+}
+
+func removeEnvFile(path, key string) error {
+	key = strings.TrimSpace(key)
+	if key == "" {
+		return nil
+	}
+	data, err := readFileUTF8(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return os.Unsetenv(key)
+		}
+		return err
+	}
+	lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+	outLines := make([]string, 0, len(lines))
+	for _, ln := range lines {
+		t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(ln), "export "))
+		if t == "" || strings.HasPrefix(t, "#") {
+			outLines = append(outLines, ln)
+			continue
+		}
+		if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
+			continue
+		}
+		outLines = append(outLines, ln)
+	}
+	out := ""
+	if len(outLines) > 0 {
+		out = strings.Join(outLines, "\n") + "\n"
+	}
+
+	dir := filepath.Dir(path)
+	if dir != "" && dir != "." {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return err
+		}
+	}
+	tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
+	if err != nil {
+		return err
+	}
+	tmpPath := tmp.Name()
+	if _, err := tmp.WriteString(out); err != nil {
+		tmp.Close()
+		os.Remove(tmpPath)
+		return err
+	}
+	if err := tmp.Close(); err != nil {
+		os.Remove(tmpPath)
+		return err
+	}
+	if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
+		os.Remove(tmpPath)
+		return err
+	}
+	return os.Unsetenv(key)
+}
diff --git a/desktop/dotenv_test.go b/desktop/dotenv_test.go
new file mode 100644
index 0000000..04da129
--- /dev/null
+++ b/desktop/dotenv_test.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"reasonix/internal/config"
+)
+
+// TestUpsertEnvFile proves a new key is appended, an existing key is replaced in
+// place, comments/other lines survive, and the process env is updated.
+func TestUpsertEnvFile(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "credentials")
+	if err := os.WriteFile(path, []byte("# comment\nFOO=old\nBAR=keep\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := upsertEnvFile(path, "FOO", "new"); err != nil {
+		t.Fatalf("replace: %v", err)
+	}
+	if err := upsertEnvFile(path, "BAZ", "added"); err != nil {
+		t.Fatalf("append: %v", err)
+	}
+
+	b, _ := os.ReadFile(path)
+	got := string(b)
+	for _, want := range []string{"# comment", "FOO=new", "BAR=keep", "BAZ=added"} {
+		if !strings.Contains(got, want) {
+			t.Errorf("missing %q in:\n%s", want, got)
+		}
+	}
+	if strings.Contains(got, "FOO=old") {
+		t.Errorf("old value should be replaced:\n%s", got)
+	}
+	if os.Getenv("FOO") != "new" || os.Getenv("BAZ") != "added" {
+		t.Errorf("process env not updated: FOO=%q BAZ=%q", os.Getenv("FOO"), os.Getenv("BAZ"))
+	}
+}
+
+func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "credentials")
+	if err := os.WriteFile(path, []byte("# comment\nFOO=old\nexport BAR=remove\nBAZ=keep\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("BAR", "remove")
+
+	if err := removeEnvFile(path, "BAR"); err != nil {
+		t.Fatalf("remove: %v", err)
+	}
+
+	b, _ := os.ReadFile(path)
+	got := string(b)
+	for _, want := range []string{"# comment", "FOO=old", "BAZ=keep"} {
+		if !strings.Contains(got, want) {
+			t.Errorf("missing %q in:\n%s", want, got)
+		}
+	}
+	if strings.Contains(got, "BAR=") {
+		t.Errorf("removed key should be absent:\n%s", got)
+	}
+	if _, ok := os.LookupEnv("BAR"); ok {
+		t.Errorf("process env BAR should be unset")
+	}
+}
+
+func TestLegacyHomeEnvProviderKeyIsNotPromoted(t *testing.T) {
+	home := isolateDesktopUserDirs(t)
+	homeEnv := filepath.Join(home, ".env")
+	if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := config.LoadForRoot(t.TempDir()); err != nil {
+		t.Fatalf("LoadForRoot: %v", err)
+	}
+	if data, err := os.ReadFile(config.UserCredentialsPath()); err == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
+		t.Errorf("legacy ~/.env provider key must not be imported:\n%s", data)
+	}
+	rest, _ := os.ReadFile(homeEnv)
+	if !strings.Contains(string(rest), "DEEPSEEK_API_KEY=sk-test") || !strings.Contains(string(rest), "NPM_TOKEN=secret") {
+		t.Errorf("legacy ~/.env should be left untouched:\n%s", rest)
+	}
+}
diff --git a/desktop/encoded_file.go b/desktop/encoded_file.go
new file mode 100644
index 0000000..17ddc54
--- /dev/null
+++ b/desktop/encoded_file.go
@@ -0,0 +1,7 @@
+package main
+
+import fileencoding "reasonix/internal/fileutil/encoding"
+
+func readFileUTF8(path string) ([]byte, error) {
+	return fileencoding.ReadFileUTF8(path)
+}
diff --git a/desktop/frontend/.gitignore b/desktop/frontend/.gitignore
new file mode 100644
index 0000000..132d0d4
--- /dev/null
+++ b/desktop/frontend/.gitignore
@@ -0,0 +1,18 @@
+node_modules
+# Build output is generated by `pnpm build` / `wails build`; keep only the
+# placeholder so the Go `//go:embed all:frontend/dist` stays compilable on a
+# fresh checkout before the first build.
+dist/*
+!dist/.gitkeep
+
+# Wails-generated bindings (we hand-write a typed bridge instead; regenerate with
+# `wails generate module` if you prefer the generated ones).
+wailsjs
+
+*.local
+
+# Wails dev install-checksum cache
+package.json.md5
+
+# This project uses pnpm (see wails.json `frontend:install`); reject the npm lockfile.
+package-lock.json
diff --git a/desktop/frontend/.npmrc b/desktop/frontend/.npmrc
new file mode 100644
index 0000000..22b0e36
--- /dev/null
+++ b/desktop/frontend/.npmrc
@@ -0,0 +1,3 @@
+# pnpm 11: don't re-check deps before `pnpm build` and never prompt to purge node_modules headless.
+confirm-modules-purge=false
+verify-deps-before-run=false
diff --git a/desktop/frontend/dist/.gitkeep b/desktop/frontend/dist/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/desktop/frontend/dist/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/desktop/frontend/index.html b/desktop/frontend/index.html
new file mode 100644
index 0000000..3ec1879
--- /dev/null
+++ b/desktop/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+  
+    
+    
+    Reasonix
+  
+  
+    
+ + + diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json new file mode 100644 index 0000000..fc36390 --- /dev/null +++ b/desktop/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "reasonix-desktop-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build", + "preview": "vite preview", + "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", + "test:todo-visibility": "node scripts/test-todo-visibility.mjs", + "typecheck": "tsc --noEmit", + "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts", + "test:typecheck": "tsc --noEmit -p tsconfig.test.json", + "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx" + }, + "dependencies": { + "@gsap/react": "^2.1.2", + "@tanstack/react-virtual": "^3.14.3", + "gsap": "^3.15.0", + "highlight.js": "^11.10.0", + "katex": "^0.17.0", + "lucide-react": "^1.21.0", + "mermaid": "^11.16.0", + "qrcode.react": "^4.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "svg-pan-zoom": "^3.6.2", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/jsdom": "^28.0.3", + "@types/node": "^26.0.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^29.1.1", + "terser": "^5.48.0", + "tsx": "^4.22.4", + "typescript": "^6.0.3", + "vite": "^8.0.16" + }, + "overrides": { + "mdast-util-gfm-autolink-literal": "2.0.0" + } +} diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..47b4288 --- /dev/null +++ b/desktop/frontend/pnpm-lock.yaml @@ -0,0 +1,3348 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + mdast-util-gfm-autolink-literal: 2.0.0 + +importers: + + .: + dependencies: + '@gsap/react': + specifier: ^2.1.2 + version: 2.1.2(gsap@3.15.0)(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.3 + version: 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + gsap: + specifier: ^3.15.0 + version: 3.15.0 + highlight.js: + specifier: ^11.10.0 + version: 11.11.1 + katex: + specifier: ^0.17.0 + version: 0.17.0 + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@19.2.7) + mermaid: + specifier: ^11.16.0 + version: 11.16.0 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 + svg-pan-zoom: + specifier: ^3.6.2 + version: 3.6.2 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7) + devDependencies: + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 + '@types/node': + specifier: ^26.0.0 + version: 26.0.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + terser: + specifier: ^5.48.0 + version: 5.48.0 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.7': + resolution: {integrity: sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@gsap/react@2.1.2': + resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==} + peerDependencies: + gsap: ^3.12.5 + react: '>=17' + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tanstack/react-virtual@3.14.3': + resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.1': + resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + katex@0.17.0: + resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + svg-pan-zoom@3.6.2: + resolution: {integrity: sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@braintree/sanitize-url@7.1.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@chevrotain/types@11.1.2': {} + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@exodus/bytes@1.15.1': {} + + '@gsap/react@2.1.2(gsap@3.15.0)(react@19.2.7)': + dependencies: + gsap: 3.15.0 + react: 19.2.7 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.1': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsdom@28.0.3': + dependencies: + '@types/node': 25.9.3 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 7.24.6 + + '@types/katex@0.16.8': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/node@26.0.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.1': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4) + + acorn@8.17.0: {} + + bail@2.0.2: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + buffer-from@1.1.2: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + entities@6.0.1: {} + + entities@8.0.0: {} + + es-toolkit@1.49.0: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + extend@3.0.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + gsap@3.15.0: {} + + hachure-fill@0.5.2: {} + + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + highlight.js@11.11.1: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-url-attributes@3.0.1: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + import-meta-resolve@4.2.0: {} + + inline-style-parser@0.2.7: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + katex@0.17.0: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lodash-es@4.18.1: {} + + longest-streak@3.1.0: {} + + lru-cache@11.5.1: {} + + lucide-react@1.21.0(react@19.2.7): + dependencies: + react: 19.2.7 + + markdown-table@3.0.4: {} + + marked@16.4.2: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.0 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.11 + es-toolkit: 1.49.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + package-manager-detector@1.6.0: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-data-parser@0.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + punycode@2.3.1: {} + + qrcode.react@4.2.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.17 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.7 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react@19.2.7: {} + + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.47 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + require-from-string@2.0.2: {} + + robust-predicates@3.0.3: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + stylis@4.4.0: {} + + svg-pan-zoom@3.6.2: {} + + symbol-tree@3.2.4: {} + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tldts-core@7.4.3: {} + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-dedent@2.3.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + undici-types@8.3.0: {} + + undici@7.28.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + uuid@14.0.1: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.0 + esbuild: 0.28.1 + fsevents: 2.3.3 + terser: 5.48.0 + tsx: 4.22.4 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-namespaces@2.0.1: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + + zwitch@2.0.4: {} diff --git a/desktop/frontend/pnpm-workspace.yaml b/desktop/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..2ed5bb5 --- /dev/null +++ b/desktop/frontend/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +overrides: + mdast-util-gfm-autolink-literal: 2.0.0 diff --git a/desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav b/desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav new file mode 100644 index 0000000..88a18e1 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav differ diff --git a/desktop/frontend/public/sounds/mixkit-positive-notification-951.wav b/desktop/frontend/public/sounds/mixkit-positive-notification-951.wav new file mode 100644 index 0000000..d2dd8d8 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-positive-notification-951.wav differ diff --git a/desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav b/desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav new file mode 100644 index 0000000..3fffc3e Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav differ diff --git a/desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav b/desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav new file mode 100644 index 0000000..22d8da9 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav differ diff --git a/desktop/frontend/scripts/check-css-syntax.mjs b/desktop/frontend/scripts/check-css-syntax.mjs new file mode 100644 index 0000000..a44ffea --- /dev/null +++ b/desktop/frontend/scripts/check-css-syntax.mjs @@ -0,0 +1,140 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const frontendRoot = path.resolve(scriptDir, ".."); +const targets = process.argv.slice(2); +const files = targets.length > 0 ? targets : ["src/styles.css"]; + +let failed = false; + +for (const file of files) { + const fullPath = path.resolve(frontendRoot, file); + const source = fs.readFileSync(fullPath, "utf8"); + const result = checkCssDelimiters(source); + if (result.ok) { + console.log(`CSS syntax check passed: ${file}`); + continue; + } + + failed = true; + console.error(`CSS syntax check failed: ${file}:${result.line}:${result.column}`); + console.error(result.message); +} + +if (failed) { + process.exit(1); +} + +function checkCssDelimiters(source) { + const stack = []; + let state = "normal"; + let line = 1; + let column = 0; + let tokenLine = 1; + let tokenColumn = 1; + + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + const next = source[i + 1]; + + if (char === "\n") { + line += 1; + column = 0; + } else { + column += 1; + } + + if (state === "comment") { + if (char === "*" && next === "/") { + i += 1; + column += 1; + state = "normal"; + } + continue; + } + + if (state === "single" || state === "double") { + if (char === "\\") { + i += 1; + column += 1; + continue; + } + if ((state === "single" && char === "'") || (state === "double" && char === '"')) { + state = "normal"; + } + continue; + } + + if (char === "/" && next === "*") { + tokenLine = line; + tokenColumn = column; + i += 1; + column += 1; + state = "comment"; + continue; + } + + if (char === "'") { + tokenLine = line; + tokenColumn = column; + state = "single"; + continue; + } + + if (char === '"') { + tokenLine = line; + tokenColumn = column; + state = "double"; + continue; + } + + if (char === "{") { + stack.push({ line, column }); + continue; + } + + if (char === "}") { + if (stack.length === 0) { + return { + ok: false, + line, + column, + message: "Found a closing brace without a matching opening brace.", + }; + } + stack.pop(); + } + } + + if (state === "comment") { + return { + ok: false, + line: tokenLine, + column: tokenColumn, + message: "Found an unterminated CSS comment.", + }; + } + + if (state === "single" || state === "double") { + return { + ok: false, + line: tokenLine, + column: tokenColumn, + message: "Found an unterminated CSS string.", + }; + } + + if (stack.length > 0) { + const opener = stack[stack.length - 1]; + return { + ok: false, + line: opener.line, + column: opener.column, + message: "Found an opening brace without a matching closing brace.", + }; + } + + return { ok: true }; +} diff --git a/desktop/frontend/scripts/check-z-index-tokens.mjs b/desktop/frontend/scripts/check-z-index-tokens.mjs new file mode 100644 index 0000000..d88a08e --- /dev/null +++ b/desktop/frontend/scripts/check-z-index-tokens.mjs @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const frontendRoot = path.resolve(scriptDir, ".."); +const targets = process.argv.slice(2); +const files = targets.length > 0 ? targets : ["src/styles.css"]; +const zIndexDecl = /z-index\s*:\s*([^;]+);/g; +const tokenValue = /^var\(--z-[a-z0-9-]+\)$/; + +let failed = false; + +for (const file of files) { + const fullPath = path.resolve(frontendRoot, file); + const source = fs.readFileSync(fullPath, "utf8"); + let match; + while ((match = zIndexDecl.exec(source)) !== null) { + const value = match[1].trim().replace(/\s+/g, " "); + if (tokenValue.test(value)) continue; + failed = true; + const line = source.slice(0, match.index).split(/\r?\n/).length; + console.error(`${file}:${line}: z-index must use a --z-* token, got ${value}`); + } +} + +if (failed) { + process.exit(1); +} + +console.log(`z-index token check passed: ${files.join(", ")}`); diff --git a/desktop/frontend/scripts/test-todo-visibility.mjs b/desktop/frontend/scripts/test-todo-visibility.mjs new file mode 100644 index 0000000..858ccfe --- /dev/null +++ b/desktop/frontend/scripts/test-todo-visibility.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourcePath = path.join(root, "src", "lib", "todoVisibility.ts"); +const source = readFileSync(sourcePath, "utf8"); +const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, +}).outputText; + +const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`; +const { + dismissedTodoKeyForScope, + scopedTodoBatchKey, + scopedTodoDismissalKey, + shouldOpenTodoPanelByDefault, + shouldShowTodoPanel, + todoBatchKey, + todoDismissalKey, + todoPanelScope, +} = await import(moduleUrl); + +const completedTodos = [ + { content: "Inspect the report", status: "completed" }, + { content: "Ship the fix", status: "completed" }, +]; +const activeTodos = [ + { content: "Inspect the report", status: "in_progress" }, + { content: "Ship the fix", status: "pending" }, +]; + +assert.equal( + shouldShowTodoPanel("todo-final", null, completedTodos), + true, + "a completed todo list stays visible in collapsed form until the user dismisses it", +); +assert.equal( + shouldShowTodoPanel("todo-active", null, [{ content: "Run tests", status: "in_progress" }]), + true, + "an active todo_write remains visible", +); +assert.equal( + shouldShowTodoPanel("todo-final", "todo-final", completedTodos), + false, + "a user dismissal still hides that exact todo list", +); +const completedKey = todoDismissalKey(completedTodos); +const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]); +assert.equal( + shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos), + false, + "a completed todo dismissal hides the list in the session where it was closed", +); +assert.equal( + shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos), + true, + "a completed todo dismissal in one session does not hide another session's list", +); +dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey)); +assert.equal( + shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos), + false, + "closing another session's todo does not forget the first session dismissal", +); +assert.equal( + todoPanelScope({ + activeTabId: "tab-a", + activeTab: { + id: "tab-a", + scope: "project", + workspaceRoot: "/repo", + topicId: "topic-a", + sessionPath: "/sessions/a.jsonl", + }, + }), + "session:/sessions/a.jsonl", + "a restored history session uses its session path as the todo panel scope", +); +assert.equal( + todoPanelScope({ + activeTabId: "tab-b", + activeTab: { + id: "tab-a", + scope: "project", + workspaceRoot: "/repo", + topicId: "topic-a", + sessionPath: "/sessions/a.jsonl", + }, + eventChannel: "desktop-events", + }), + "tab:tab-b", + "a stale active-tab fallback must not scope dismissal to the previous session", +); +assert.equal( + todoPanelScope({ + activeTabId: "tab-c", + activeTab: { + id: "tab-c", + scope: "project", + workspaceRoot: "/repo", + topicId: "topic-a", + }, + }), + "tab:tab-c", + "an unsaved topic session uses its tab id so sibling sessions do not share todo state", +); +assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel"); +assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel"); + +const activeKey = todoDismissalKey(activeTodos); +assert.equal( + activeKey, + todoDismissalKey(activeTodos.map((todo) => ({ ...todo }))), + "the same task list keeps a stable dismissal key across restored event ids", +); +assert.equal( + shouldShowTodoPanel(activeKey, activeKey, activeTodos), + true, + "an incomplete restored todo list must reappear even after a stale local dismissal", +); +assert.notEqual( + activeKey, + todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]), + "real progress produces a fresh dismissal key", +); +assert.equal( + todoBatchKey(activeTodos), + todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]), + "status progress stays in the same todo batch", +); +assert.equal( + scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)), + scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])), + "status progress keeps the same scoped open-state key", +); +assert.notEqual( + scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)), + scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)), + "the same task list in a different session gets isolated open state", +); +assert.notEqual( + todoBatchKey(activeTodos), + todoBatchKey([{ content: "Run a different task", status: "in_progress" }]), + "new task content creates a new todo batch", +); +assert.equal( + shouldOpenTodoPanelByDefault(), + false, + "todo batches collapse by default regardless of completion", +); + +const iterations = 200_000; +const started = performance.now(); +for (let i = 0; i < iterations; i += 1) { + if (shouldShowTodoPanel("todo-perf", "todo-perf", completedTodos)) { + throw new Error("unexpected visible todo panel during performance loop"); + } +} +const elapsed = performance.now() - started; +const perCallUs = (elapsed * 1000) / iterations; + +assert.ok(elapsed < 500, `todo visibility check is too slow: ${elapsed.toFixed(2)} ms`); +console.log( + `todo visibility checks: ${iterations} calls in ${elapsed.toFixed(2)} ms (${perCallUs.toFixed(3)} us/call)`, +); diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx new file mode 100644 index 0000000..3ec2c89 --- /dev/null +++ b/desktop/frontend/src/App.tsx @@ -0,0 +1,4111 @@ +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; +import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; +import gsap from "gsap"; +import { useGSAP } from "@gsap/react"; +import { Flip } from "gsap/Flip"; +import { ScrollToPlugin } from "gsap/ScrollToPlugin"; +gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin); +import { + Activity, + CircleHelp, + Command, + Copy as RestoreIcon, + Download, + Minus, + Search, + Square, + SquarePen, + PanelLeft, + PanelRight, + FileDown, + FileImage, + FileText, + FileJson, + GitBranch, + History, + MessageSquare, + Settings as SettingsIcon, + Pencil, + Trash2, + AlarmClock, + Brain, + Cpu, + Palette, + X, +} from "lucide-react"; +import { useToast } from "./lib/toast"; +import { useWailsResizeFix } from "./lib/useWailsResizeFix"; +import { asArray } from "./lib/array"; +import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT, type Translator } from "./lib/i18n"; +import { localizedNoticeText, useController, type Item, type LiveStream } from "./lib/useController"; +import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered } from "./lib/bridge"; +import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music"; +import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound"; +import { NoticeCard, Transcript } from "./components/Transcript"; +import { Composer } from "./components/Composer"; +import { TodoPanel } from "./components/TodoPanel"; +import { ApprovalModal } from "./components/ApprovalModal"; +import { AskCard } from "./components/AskCard"; +import { UndoRewindBanner } from "./components/UndoRewindBanner"; +import { ClearContextCard } from "./components/ClearContextCard"; + +/** Footer decision surface kinds. Priority: tool/plan approval > ask > clear context. */ +type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "clear_context"; +import { StatusBar } from "./components/StatusBar"; +import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; +import { UpdateBanner } from "./components/UpdateBanner"; +import { ContextPanel } from "./components/ContextPanel"; +import { WorkspacePanel } from "./components/WorkspacePanel"; +import { Tooltip } from "./components/Tooltip"; +import { StartupSplash } from "./components/StartupSplash"; +import { OnboardingOverlay } from "./components/OnboardingOverlay"; +import { AppChrome } from "./components/AppChrome"; +import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet"; +import { ProjectTree } from "./components/ProjectTree"; +import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel"; +import "./custom/features/heartbeat/heartbeat.css"; +import { CopyButton } from "./components/CopyButton"; +import { parseTodos } from "./lib/tools"; +import { + dismissedTodoKeyForScope, + scopedTodoBatchKey, + scopedTodoDismissalKey, + shouldShowTodoPanel, + todoBatchKey, + todoDismissalKey, + todoPanelScope, +} from "./lib/todoVisibility"; +import { + type BotConnectionView, + type BotRuntimeStatusView, + type BotSettingsView, + type CollaborationMode, + type ComposerInsertRequest, + type DesktopStartupSettingsView, + type Mode, + modeHasPlan, + type ProjectNode, + type SessionMeta, + type SettingsView, + type TabMeta, + type TokenMode, + type ToolApprovalMode, +} from "./lib/types"; +import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay"; +import { + composerProfileFromMeta, + composerProfileFromTab, + composerProfileMode, + composerProfileWithMode, + controllerComposerProfileCollaborationMode, + defaultComposerProfile, + displayedComposerProfileCollaborationMode, + hydrateComposerProfileFromMeta, + hydrateComposerProfilesFromTabs, + patchComposerProfile, + pruneUserPlanModeIntents, + resolvePlanRestoreTabId, + shouldRestoreUserPlanModeForProfile, + updateUserPlanModeIntent, + type ComposerProfile, + type ComposerProfileField, + type UserPlanModeIntents, +} from "./lib/composerProfile"; +import { + restorableToolApprovalMode, + toggleYoloToolApprovalMode, + type RestorableToolApprovalMode, +} from "./lib/toolApprovalMode"; +import { + CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH, + CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, + CREATION_SIDEBAR_MIN_WIDTH, + RIGHT_DOCK_MAX_WIDTH, + RIGHT_DOCK_MIN_RENDER_WIDTH, + RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH, + RIGHT_DOCK_PREVIEW_MIN_WIDTH, + RIGHT_DOCK_TREE_MAX_WIDTH, + RIGHT_DOCK_TREE_MIN_WIDTH, + type RightDockMode, + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_WIDTH, + applyLayoutStyleDefaults, + clampCreationRightDockTreeWidth, + clampCreationSidebarWidth, + clampRightDockPreviewWidth, + clampRightDockTreeWidth, + clampSidebarWidth, + defaultCreationRightDockTreeWidth, + defaultCreationSidebarWidth, + defaultRightDockTreeWidth, + defaultSidebarWidth, + saveRightDockPreviewWidth, + saveRightDockTreeWidth, + saveSidebarCollapsed, + saveSidebarWidth, + useLayoutStore, +} from "./store/layout"; +import { useOverlayStore } from "./store/overlays"; +import { hydrateDisplayMode } from "./lib/displayMode"; +import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "./lib/statusBarItems"; +import { paletteSessionDisplayTitle, paletteSessionHint, paletteSessionKeywords, sessionActivityTime } from "./lib/session"; +import { enqueueNavigationRequest, type PendingNavigationRequest } from "./lib/openTopicCoalescing"; +import { + applyTheme, + clearLegacyThemePreference, + getTheme, + getThemeStyle, + isThemeStyle, + normalizeThemePreference, + normalizeThemeStyleForTheme, + readLegacyThemePreference, + type Theme, +} from "./lib/theme"; +import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize"; +import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState"; +import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout"; +import { createRafResizeUpdater } from "./lib/resizeDrag"; +import { useGlobalShortcut } from "./lib/keyboardShortcuts"; +import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts"; +import { composerDraftKeyForTab } from "./lib/composerDraftKey"; +import logoWordmark from "./assets/logo-wordmark.svg"; + +function noticePreviewMockEnabled(): boolean { + const value = browserMockScenarioParam(); + return value === "notice" || value === "notices" || value === "notice-preview"; +} + +function noticePreviewItems(): Item[] { + const notice = (index: number, level: "info" | "warn", text: string, detail: string, code?: string): Item => ({ + kind: "notice", + id: `notice-preview-${index}`, + level, + text: localizedNoticeText(text, code), + detail, + }); + return [ + { + kind: "notice", + id: "notice-preview-delivery", + level: "info", + variant: "delivery", + title: t("notice.deliveryIncompleteTitle"), + text: t("notice.deliveryIncompleteBody"), + detail: "final-answer readiness failed 3 times: missing verification, review_report, and complete_step receipts", + action: "continue_delivery", + }, + notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying", "empty_final"), + notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute", "executor_handoff"), + notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing", "tool_budget"), + notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts", "loop_guard"), + notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"), + notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"), + notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"), + notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"), + notice(9, "info", "Planning mode enabled for this multi-step task.", "auto plan detector matched implementation task with repository edits and verification steps"), + notice(10, "info", "Plan detection requested a plan.", "plan detector confidence=0.83; task includes multiple dependent UI/backend changes"), + notice(11, "info", "Plan detection was uncertain; using the fallback planner heuristic.", "plan detector confidence=0.48; fallback heuristic selected because files and tests are involved"), + notice(12, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"), + notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"), + notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"), + notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"), + notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"), + notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"), + notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"), + notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""), + notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""), + notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""), + notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"), + notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"), + notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"), + notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"), + notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"), + ]; +} + +function NoticePreviewPanel() { + return ( +
+
+ {noticePreviewItems().map((item) => { + if (item.kind !== "notice") return null; + return undefined : undefined} />; + })} +
+
+ ); +} + +const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel }))); +const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel }))); + +const CHAT_MIN_WIDTH = 400; +const CHAT_COMFORT_MIN_WIDTH = 560; +const WORKSPACE_RESIZER_WIDTH = 8; + +function stripGoalResearchFlags(arg: string): string { + const parts = arg.trim().split(/\s+/).filter(Boolean); + while (parts.length > 0) { + const flag = parts[0].toLowerCase(); + if (flag !== "--research" && flag !== "--auto-research" && flag !== "--deep" && flag !== "--simple" && flag !== "--no-research") break; + parts.shift(); + } + return parts.join(" "); +} + +function hasGoalResearchFlag(arg: string): boolean { + const first = arg.trim().split(/\s+/, 1)[0]?.toLowerCase(); + return first === "--research" || first === "--auto-research" || first === "--deep" || first === "--simple" || first === "--no-research"; +} + +function isThemeMode(value: string): value is Theme { + return value === "auto" || value === "light" || value === "dark"; +} + +type DesktopLayoutStyle = "classic" | "workbench" | "creation"; + +function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutStyle { + if (style === "workbench") return "workbench"; + if (style === "creation") return "creation"; + return "classic"; +} +const SHOW_CONTEXT_DOCK = true; +const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys"; +const MAX_DISMISSED_TODO_KEYS = 160; +type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string }; +type WorkspaceInsertTarget = "composer" | "planRevision"; +type DesktopPlatform = "darwin" | "windows" | "linux"; + +function WindowsWindowControls() { + const [maximised, setMaximised] = useState(false); + + const syncMaximised = useCallback(() => { + void app.IsMainWindowMaximised() + .then(setMaximised) + .catch(() => setMaximised(false)); + }, []); + + useEffect(() => { + syncMaximised(); + window.addEventListener("resize", syncMaximised); + window.addEventListener("focus", syncMaximised); + return () => { + window.removeEventListener("resize", syncMaximised); + window.removeEventListener("focus", syncMaximised); + }; + }, [syncMaximised]); + + const toggleMaximise = useCallback(() => { + void app.ToggleMaximiseMainWindow() + .then(() => window.setTimeout(syncMaximised, 80)) + .catch(() => undefined); + }, [syncMaximised]); + + return ( +
+ + + +
+ ); +} +type HistoryViewState = + | { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] } + | { kind: "history"; source: "all"; sessions: SessionMeta[] } + | { kind: "trash"; sessions: SessionMeta[] }; +type SidebarImPlatform = "qq" | "feishu" | "lark" | "weixin"; +type SidebarImStatus = "connected" | "disabled" | "pending" | "error" | "disconnected"; +type SidebarImConnection = { + id: string; + connectionId: string; + platform: SidebarImPlatform; + title: string; + platformLabel: string; + subtitle: string; + status: SidebarImStatus; + statusLabel: string; + remoteId: string; + sessionId: string; + sessionSource: string; + scope: "global" | "project"; + workspaceRoot: string; + allowAll: boolean; + allowlistEnabled: boolean; + allowlistUsers: string[]; + allowlistMatched: boolean; +}; +type DesktopNavigationInput = + | { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string } + | { kind: "blank"; scope: string; workspaceRoot: string } + | { kind: "sidebar-im"; connection: SidebarImConnection } + | { kind: "resume-session"; session: SessionMeta }; +type PendingDesktopNavigationRequest = PendingNavigationRequest; +type SidebarImTopicSource = { + platform: SidebarImPlatform; + label: string; + title: string; + remoteId: string; + connectionId: string; +}; +type SidebarImConnectionDetailProps = { + connection: SidebarImConnection; + onClose: () => void; + onOpenSession: () => void; + onOpenSettings: () => void; + onManageAllowlist: () => void; +}; + +function loadDismissedTodoKeys(): Set { + try { + const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY); + if (!saved) return new Set(); + const parsed = JSON.parse(saved) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0)); + } catch { + return new Set(); + } +} + +function saveDismissedTodoKeys(keys: ReadonlySet): void { + try { + window.localStorage.setItem( + DISMISSED_TODO_STORAGE_KEY, + JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)), + ); + } catch { + /* ignore quota errors */ + } +} + +function isSidebarImConnection(connection: BotConnectionView): boolean { + return connection.provider === "feishu" || connection.provider === "weixin"; +} + +function sidebarImPlatform(connection: BotConnectionView): SidebarImPlatform { + if (connection.provider === "weixin") return "weixin"; + return connection.domain === "lark" ? "lark" : "feishu"; +} + +function sidebarImPlatformLabel(platform: SidebarImPlatform, translate: Translator): string { + if (platform === "qq") return "QQ"; + if (platform === "lark") return "Lark"; + if (platform === "weixin") return translate("settings.botWeixin"); + return translate("settings.botFeishu"); +} + +function botMappingScope(mapping: BotConnectionView["sessionMappings"][number] | null | undefined, connectionWorkspaceRoot: string): "global" | "project" { + if (mapping?.scope === "project") return "project"; + if ((mapping?.workspaceRoot ?? "").trim()) return "project"; + return connectionWorkspaceRoot.trim() ? "project" : "global"; +} + +function botMappingWorkspaceRoot( + mapping: BotConnectionView["sessionMappings"][number] | null | undefined, + connectionWorkspaceRoot: string, +): string { + const workspaceRoot = (mapping?.workspaceRoot ?? "").trim() || connectionWorkspaceRoot.trim(); + return botMappingScope(mapping, connectionWorkspaceRoot) === "project" ? workspaceRoot : ""; +} + +function compactRemoteId(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= 28) return trimmed; + return `${trimmed.slice(0, 12)}…${trimmed.slice(-8)}`; +} + +function botMappingIdentityLabel(mapping: BotConnectionView["sessionMappings"][number] | null | undefined): string { + const chatType = (mapping?.chatType ?? "").trim(); + const userId = (mapping?.userId ?? "").trim(); + const threadId = (mapping?.threadId ?? "").trim(); + if (threadId) return compactRemoteId(threadId); + if ((chatType === "group" || chatType === "guild") && userId) return compactRemoteId(userId); + return ""; +} + +function sidebarImStatus(connection: BotConnectionView, botEnabled: boolean): SidebarImStatus { + if (!botEnabled || !connection.enabled) return "disabled"; + if (connection.status === "connected") return "connected"; + if (connection.status === "pending") return "pending"; + if (connection.status === "error") return "error"; + return "disconnected"; +} + +function sidebarImStatusLabel(status: SidebarImStatus, translate: Translator): string { + switch (status) { + case "connected": + return translate("sidebar.imConnected"); + case "disabled": + return translate("sidebar.imDisabled"); + case "pending": + return translate("sidebar.imPending"); + case "error": + return translate("sidebar.imError"); + default: + return translate("sidebar.imDisconnected"); + } +} + +function uniqueTrimmedValues(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] { + if (platform === "qq") return uniqueTrimmedValues(asArray(bot.allowlist.qqUsers)); + if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers)); + return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers)); +} + +function sidebarImQQAdded(qq: BotSettingsView["qq"]): boolean { + return Boolean(qq.enabled || qq.secretSet || qq.appId.trim()); +} + +function sidebarImQQStatus(bot: BotSettingsView, runtimeStatus: BotRuntimeStatusView | null | undefined): SidebarImStatus { + const appId = bot.qq.appId.trim(); + if (!bot.enabled || !bot.qq.enabled) return "disabled"; + if (!appId || !bot.qq.secretSet) return "disconnected"; + if (typeof window !== "undefined" && !window.runtime) return "pending"; + if (!runtimeStatus) return "pending"; + const status = runtimeStatus.status.trim().toLowerCase(); + if (runtimeStatus.running && runtimeStatus.connections > 0 && status === "running") { + return "connected"; + } + if (status === "error" || status === "blocked" || status === "degraded") return "error"; + if (status === "stopped") return "disconnected"; + return "pending"; +} + +async function loadBotRuntimeStatus(): Promise { + if (typeof window !== "undefined" && !window.runtime) return null; + try { + return await app.BotRuntimeStatus(); + } catch (e) { + console.warn("bot runtime status failed", e); + return null; + } +} + +function sidebarImQQConnection(bot: BotSettingsView, translate: Translator, runtimeStatus?: BotRuntimeStatusView | null): SidebarImConnection | null { + if (!sidebarImQQAdded(bot.qq)) return null; + const remoteId = bot.qq.appId.trim(); + const status = sidebarImQQStatus(bot, runtimeStatus); + const statusLabel = sidebarImStatusLabel(status, translate); + const allowlistUsers = sidebarImAllowlistUsers(bot, "qq"); + const subtitleParts = [ + remoteId ? compactRemoteId(remoteId) : "QQ", + statusLabel, + ].filter(Boolean); + return { + id: "__qq_bot__", + connectionId: "__qq_bot__", + platform: "qq", + title: "QQ Bot", + platformLabel: "QQ", + subtitle: subtitleParts.join(" · "), + status, + statusLabel, + remoteId, + sessionId: "", + sessionSource: "", + scope: "global", + workspaceRoot: "", + allowAll: bot.allowlist.allowAll, + allowlistEnabled: bot.allowlist.enabled, + allowlistUsers, + allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, + }; +} + +function sidebarImConnectionsFromBot( + bot: BotSettingsView | null | undefined, + translate: Translator, + runtimeStatus?: BotRuntimeStatusView | null, +): SidebarImConnection[] { + if (!bot) return []; + const qqConnection = sidebarImQQConnection(bot, translate, runtimeStatus); + const connectionItems: SidebarImConnection[] = []; + for (const connection of asArray(bot.connections)) { + if (!isSidebarImConnection(connection)) continue; + const mappings = connection.sessionMappings.filter((mapping) => mapping.sessionId.trim() || mapping.remoteId.trim()); + const rowMappings = mappings.length > 0 ? mappings : [null]; + rowMappings.forEach((mapping, index) => { + const platform = sidebarImPlatform(connection); + const platformLabel = sidebarImPlatformLabel(platform, translate); + const remoteId = mapping?.remoteId.trim() ?? ""; + const sessionId = mapping?.sessionId.trim() ?? ""; + const sessionSource = mapping?.sessionSource.trim() ?? ""; + const scope = botMappingScope(mapping, connection.workspaceRoot); + const workspaceRoot = botMappingWorkspaceRoot(mapping, connection.workspaceRoot); + const status = sidebarImStatus(connection, bot.enabled); + const title = connection.label.trim() || platformLabel; + const allowlistUsers = sidebarImAllowlistUsers(bot, platform); + const identityLabel = botMappingIdentityLabel(mapping); + const mappedUserId = mapping?.userId.trim() ?? ""; + const subtitleParts = [ + remoteId ? compactRemoteId(remoteId) : platformLabel, + identityLabel, + connection.model.trim() || "", + sidebarImStatusLabel(status, translate), + ].filter(Boolean); + connectionItems.push({ + id: mapping ? `${connection.id}:mapping:${index}` : connection.id, + connectionId: connection.id, + platform, + title, + platformLabel, + subtitle: subtitleParts.join(" · "), + status, + statusLabel: sidebarImStatusLabel(status, translate), + remoteId, + sessionId, + sessionSource, + scope, + workspaceRoot, + allowAll: bot.allowlist.allowAll, + allowlistEnabled: bot.allowlist.enabled, + allowlistUsers, + allowlistMatched: remoteId + ? allowlistUsers.includes(remoteId) || (mappedUserId ? allowlistUsers.includes(mappedUserId) : false) + : false, + }); + }); + } + return qqConnection ? [qqConnection, ...connectionItems] : connectionItems; +} + +function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null { + const trimmed = sessionId.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + if (lower.startsWith("path:")) { + const value = trimmed.slice(5).trim(); + return value ? { kind: "path", value } : null; + } + if (lower.startsWith("topic:")) { + const value = trimmed.slice(6).trim(); + return value ? { kind: "topic", value } : null; + } + if (trimmed.endsWith(".jsonl") || trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith("~")) { + return { kind: "path", value: trimmed }; + } + return { kind: "topic", value: trimmed }; +} + +function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null { + return mappedSessionTarget(connection.sessionId); +} + +function isChannelSession(session: SessionMeta): boolean { + return session.kind === "channel" || session.sessionSource === "auto"; +} + +function sidebarImTopicSourcesFromBot(bot: BotSettingsView | null | undefined, translate: Translator): Record { + if (!bot?.connections?.length) return {}; + const sources: Record = {}; + for (const connection of bot.connections) { + if (!isSidebarImConnection(connection)) continue; + const platform = sidebarImPlatform(connection); + const label = sidebarImPlatformLabel(platform, translate); + const title = connection.label.trim() || label; + for (const mapping of asArray(connection.sessionMappings)) { + const scope = botMappingScope(mapping, connection.workspaceRoot); + if (scope !== "global") continue; + const target = mappedSessionTarget(mapping.sessionId); + if (!target || target.kind !== "topic") continue; + if (sources[target.value]) continue; + sources[target.value] = { + platform, + label, + title, + remoteId: mapping.remoteId.trim(), + connectionId: connection.id, + }; + } + } + return sources; +} + +function sidebarImScopeLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.scope === "project") return translate("botDetail.scopeProject", { name: connection.workspaceRoot || "Project" }); + return translate("botDetail.scopeGlobal"); +} + +function sidebarImSessionLabel(connection: SidebarImConnection, translate: Translator): string { + const target = sidebarImSessionTarget(connection); + if (!target) { + return connection.remoteId ? translate("botDetail.readOnlyChannel") : translate("botDetail.noSession"); + } + if (connection.sessionSource === "auto") return translate("botDetail.readOnlyChannel"); + if (target.kind === "path") return target.value.split(/[\\/]/).pop() || target.value; + return target.value; +} + +function sidebarImAccessModeLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.allowAll) return translate("botDetail.accessAllowAll"); + if (connection.allowlistEnabled) return translate("botDetail.accessWhitelist"); + return translate("botDetail.accessDisabled"); +} + +function sidebarImAccessStatusLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.allowAll) return translate("botDetail.accessOpen"); + if (!connection.remoteId) return translate("botDetail.accessUnknown"); + return connection.allowlistMatched ? translate("botDetail.accessMatched") : translate("botDetail.accessMissing"); +} + +function sidebarImAccessStatusClass(connection: SidebarImConnection): string { + if (connection.allowAll || connection.allowlistMatched) return "ok"; + if (!connection.remoteId) return "muted"; + return "warn"; +} + +function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenSettings, onManageAllowlist }: SidebarImConnectionDetailProps) { + const translate = useT(); + const target = sidebarImSessionTarget(connection); + const accessStatusClass = sidebarImAccessStatusClass(connection); + return ( +
+
+ +
+ {translate("botDetail.subtitle")} +

{connection.title}

+
+ {connection.platformLabel} + {connection.statusLabel} + {sidebarImScopeLabel(connection, translate)} +
+
+
+ + + +
+
+ +
+
+ {translate("botDetail.access")} +
+ {connection.remoteId ? ( + + ) : null} + +
+
+
+
+ {translate("botDetail.accessMode")} + {sidebarImAccessModeLabel(connection, translate)} +
+
+ {translate("botDetail.accessCurrentUser")} + {connection.remoteId || "—"} +
+
+ {translate("botDetail.accessStatus")} + + {sidebarImAccessStatusLabel(connection, translate)} + +
+
+
+ {translate("botDetail.channelAllowlistUsers")} +
+ {connection.allowlistUsers.length > 0 ? ( + connection.allowlistUsers.map((id) => ( + + {id} + + )) + ) : ( + {translate("botDetail.emptyAllowlistUsers")} + )} +
+
+
+ +
+
+ {translate("botDetail.summary")} +
+
+
+ {translate("botDetail.remoteId")} + {connection.remoteId || "—"} +
+
+ {translate("botDetail.localTopic")} + {sidebarImSessionLabel(connection, translate)} +
+
+ {translate("botDetail.scope")} + {sidebarImScopeLabel(connection, translate)} +
+
+
+
+ ); +} + +function activeTopicTurnsFromTree(tree: ProjectNode[], tab?: TabMeta): number | undefined { + if (!tab?.topicId) return undefined; + const targetScope = tab.scope === "global" ? "global" : "project"; + const walk = (nodes: ProjectNode[]): number | undefined => { + for (const node of nodes) { + if (!node) continue; + if (node.kind === "topic" || node.kind === "global_topic") { + const scope = node.kind === "global_topic" ? "global" : "project"; + if ( + scope === targetScope && + node.topicId === tab.topicId && + (scope === "global" || node.root === tab.workspaceRoot) + ) { + return node.turns; + } + } + const found = walk(asArray(node.children)); + if (found !== undefined) return found; + } + return undefined; + }; + return walk(tree); +} + +function normalizeDesktopPlatform(value: string): DesktopPlatform { + if (value === "darwin" || value === "windows") return value; + return "linux"; +} + +function browserPlatformOverride(): DesktopPlatform | null { + if (typeof window === "undefined" || window.runtime) return null; + const value = new URLSearchParams(window.location.search).get("platform"); + if (value === "darwin" || value === "windows" || value === "linux") return value; + return null; +} + +const GUIDANCE_QUEUE_MOCK_ITEMS = [ + "先确认发送后输入框为什么残留刚发的消息,再决定修哪里。", + "保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。", + "最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。", +] as const; + +function browserMockScenarioParam(): string { + if (typeof window === "undefined" || window.runtime) return ""; + return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? ""; +} + +function isGuidanceMockScenario(value: string): boolean { + return value === "guidance" || value === "guide" || value === "steer"; +} + +function detectBrowserPlatform(): DesktopPlatform { + const override = browserPlatformOverride(); + if (override) return override; + if (typeof navigator === "undefined") return "linux"; + const marker = `${navigator.platform} ${navigator.userAgent}`; + if (/Win/i.test(marker)) return "windows"; + if (/Mac/i.test(marker)) return "darwin"; + return "linux"; +} + +function tabWorkspaceTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + if (tab.scope === "project") return tab.workspaceName || tab.workspaceRoot || "Project"; + if (tab.scope === "global") return tab.workspaceName || "Global"; + return tab.workspaceName || tab.workspaceRoot || "Global"; +} + +function topicTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + const workspaceTitle = tabWorkspaceTitle(tab); + const topic = tab.topicTitle || (tab.scope === "global" ? workspaceTitle : "Untitled"); + return topic === workspaceTitle ? workspaceTitle : `${workspaceTitle} / ${topic}`; +} + +function topicDisplayTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + return tab.topicTitle || (tab.scope === "global" ? tabWorkspaceTitle(tab) : "Untitled"); +} + +function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] { + if (filter.scope === "project") { + return sessions.filter((session) => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot); + } + return sessions.filter((session) => (session.scope || "global") === "global"); +} + +function isMissingSessionError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? ""); + return /no such file|cannot find the file|file does not exist|session is pending cleanup|session .*not found/i.test(message); +} + +function workspaceDisplayName(path?: string): string { + if (!path) return ""; + const parts = path.split(/[/\\]/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : path; +} + +function materializeLiveItems(items: Item[], live?: LiveStream): Item[] { + if (!live) return items; + return items.map((item) => { + if (item.kind !== "assistant" || item.id !== live.id) return item; + return { ...item, text: live.text, reasoning: live.reasoning, streaming: true }; + }); +} + +function fence(label: string, value: string): string { + if (!value.trim()) return ""; + const fenceToken = value.includes("```") ? "````" : "```"; + return `${label}\n${fenceToken}\n${value.trim()}\n${fenceToken}`; +} + +function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream): string { + const lines: string[] = [`# ${title.trim() || "Reasonix session"}`, ""]; + for (const item of materializeLiveItems(items, live)) { + switch (item.kind) { + case "user": + lines.push("## User", "", item.text.trim(), ""); + break; + case "assistant": + lines.push("## Assistant"); + if (item.reasoning.trim()) { + lines.push("", "### Reasoning", "", item.reasoning.trim()); + } + if (item.text.trim()) { + lines.push("", item.text.trim()); + } + lines.push(""); + break; + case "tool": + lines.push(`### Tool: ${item.name}`); + if (item.args.trim()) lines.push("", fence("Args", item.args)); + if (item.output?.trim()) lines.push("", fence("Output", item.output)); + if (item.error?.trim()) lines.push("", fence("Error", item.error)); + lines.push(""); + break; + case "phase": + lines.push(`### Phase`, "", item.text.trim(), ""); + break; + case "notice": + lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), ""); + if (item.detail?.trim()) { + lines.push("Details:", "", item.detail.trim(), ""); + } + break; + case "compaction": + lines.push("### Context Compaction", ""); + if (item.pending) { + lines.push("Compaction pending."); + } else { + lines.push(`Messages: ${item.messages}`); + if (item.trigger) lines.push(`Trigger: ${item.trigger}`); + if (item.summary.trim()) lines.push("", item.summary.trim()); + } + lines.push(""); + break; + } + } + return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; +} + +function sessionItemsToJson(title: string, items: Item[], live?: LiveStream): string { + return JSON.stringify( + { + title, + exportedAt: new Date().toISOString(), + items: materializeLiveItems(items, live), + }, + null, + 2, + ); +} + +function safeFilename(name: string): string { + const cleaned = name.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80); + return cleaned || "reasonix-session"; +} + +/** Global hotkey handler for shell-expand toggle (Ctrl/Cmd+B). */ +function ShellHotkeys() { + const shellExpand = useShellExpand(); + useGlobalShortcut("shell.toggle", () => shellExpand?.toggleLast(), [shellExpand], Boolean(shellExpand)); + return null; +} + +/** Global hotkey handler for text-size shortcuts (Ctrl/Cmd + Plus/Minus/0). */ +function TextSizeHotkeys() { + useGlobalShortcut("textSize.increase", () => applyTextSize(nextTextSize(getTextSize(), 1))); + useGlobalShortcut("textSize.decrease", () => applyTextSize(nextTextSize(getTextSize(), -1))); + useGlobalShortcut("textSize.reset", () => applyTextSize(DEFAULT_TEXT_SIZE)); + return null; +} + +export default function App() { + const { + state, + activeTabId, + sendToTab, + runShellForTab, + steerForTab, + notice, + cancel, + approve, + answerQuestion, + setControllerMode, + setCollaborationMode: setControllerCollaborationMode, + setCollaborationModeForTab: setControllerCollaborationModeForTab, + setToolApprovalMode: setControllerToolApprovalMode, + setToolApprovalModeForTab: setControllerToolApprovalModeForTab, + setGoal: setControllerGoal, + setGoalForTab: setControllerGoalForTab, + clearGoal: clearControllerGoal, + clearSession, + listSessions, + listTrashedSessions, + resumeSession, + openChannelSession, + previewSession, + deleteSession, + restoreSession, + purgeTrashedSession, + renameSession, + loadOlderHistory, + refreshMeta, + pickWorkspace, + switchWorkspace, + rewindForTab, + setModel, + setEffort, + setTokenMode, + switchTab, + openProjectTab, + openGlobalTab, + closeTab, + reorderTabs, + openTopicSession, + activateTopic, + syncActiveTab, + ensureBlankTab, + ensureBlankSurface, + } = useController(); + const { locale, setPref: setLocalePref } = useI18n(); + const t = useT(); + const [composerProfilesByTab, setComposerProfilesByTab] = useState>({}); + const yoloRestoreToolApprovalModesRef = useRef>({}); + const userPlanModeByTabRef = useRef({}); + const [tabMetas, setTabMetas] = useState([]); + const [tabOrderIds, setTabOrderIds] = useState([]); + const [tabRevealSignal, setTabRevealSignal] = useState(0); + const [transcriptRevealSignal, setTranscriptRevealSignal] = useState(0); + const startupSplashVisible = useOverlayStore((s) => s.startupSplashVisible); + const setStartupSplashVisible = useOverlayStore((s) => s.setStartupSplashVisible); + // null until the mount probe resolves; true shows the overlay. Probed once — + // clearing the key mid-session is the Settings panel's job, not the gate's. + const needsOnboarding = useOverlayStore((s) => s.needsOnboarding); + const setNeedsOnboarding = useOverlayStore((s) => s.setNeedsOnboarding); + const settingsTarget = useOverlayStore((s) => s.settingsTarget); + const setSettingsTarget = useOverlayStore((s) => s.setSettingsTarget); + const settingsFocus = useOverlayStore((s) => s.settingsFocus); + const setSettingsFocus = useOverlayStore((s) => s.setSettingsFocus); + const [desktopLayoutStyle, setDesktopLayoutStyle] = useState("workbench"); + const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation"; + const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState(null); + const [histView, setHistView] = useState(null); + const paletteOpen = useOverlayStore((s) => s.paletteOpen); + const setPaletteOpen = useOverlayStore((s) => s.setPaletteOpen); + const shortcutsOpen = useOverlayStore((s) => s.shortcutsOpen); + const setShortcutsOpen = useOverlayStore((s) => s.setShortcutsOpen); + const paletteSessions = useOverlayStore((s) => s.paletteSessions); + const setPaletteSessions = useOverlayStore((s) => s.setPaletteSessions); + const { showToast } = useToast(); + const [sidebarImConnections, setSidebarImConnections] = useState([]); + const [imTopicSources, setImTopicSources] = useState>({}); + const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState(""); + const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed); + const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed); + const heartbeatOpen = useOverlayStore((s) => s.heartbeatOpen); + const setHeartbeatOpen = useOverlayStore((s) => s.setHeartbeatOpen); + type TimeFilter = "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d"; + const [topicTimeFilter, setTopicTimeFilter] = useState(() => { + try { + const saved = localStorage.getItem("projectTree:timeFilter"); + if (saved === "all" || saved === "10" || saved === "20" || saved === "1h" || saved === "3h" || saved === "5h" || saved === "1d") return saved; + } catch { /* localStorage unavailable */ } + return "all"; + }); + useEffect(() => { + try { localStorage.setItem("projectTree:timeFilter", topicTimeFilter); } catch { /* ignore */ } + }, [topicTimeFilter]); + const sidebarWidth = useLayoutStore((s) => s.sidebarWidth); + const setSidebarWidth = useLayoutStore((s) => s.setSidebarWidth); + const [sidebarResizing, setSidebarResizing] = useState(false); + const [liveSidebarWidth, setLiveSidebarWidth] = useState(null); + const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth)); + const workspacePanelOpen = useLayoutStore((s) => s.workspacePanelOpen); + const setWorkspacePanelOpen = useLayoutStore((s) => s.setWorkspacePanelOpen); + const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth); + const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth); + const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth); + const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth); + const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive); + const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive); + const attentionChimeEvents = useRef(new Set()); + const workspaceScopeActiveTabRef = useRef(activeTabId); + const [workspaceControllerEpoch, setWorkspaceControllerEpoch] = useState(0); + workspaceScopeActiveTabRef.current = activeTabId; + // Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch + // workspace changes, git history, and session metadata after AI tool writes. + useEffect(() => { + const unsub = onEvent((e) => { + if (e.kind === "turn_done") { + setDockRefreshKey((v) => v + 1); + } + if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) { + playAttentionChime(); + } + if (e.kind === "turn_done") { + if (!e.err) playSuccessChime(); + } + }); + // Runtime rebuilds (model/effort/settings switch) replace the controller, + // whose approval/ask ids restart from "1" — stale dedupe keys would mute + // the first prompt after a rebuild. agent:ready fires when a (re)build + // completes; clear that tab's keys (or all, for tab-less ready events). + const unsubReady = onReady((readyTabId) => { + clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId); + if (!readyTabId || readyTabId === workspaceScopeActiveTabRef.current) { + setWorkspaceControllerEpoch((value) => value + 1); + } + }); + // Model/effort/token-mode switches and clear-while-running replace the + // controller WITHOUT an agent:ready — they signal runtime:rebuilt instead + // (a ready here would trigger a full session reload the UI already did). + const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => { + clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId); + if (!rebuiltTabId || rebuiltTabId === workspaceScopeActiveTabRef.current) { + setWorkspaceControllerEpoch((value) => value + 1); + } + }); + return () => { + unsub(); + unsubReady(); + unsubRebuilt(); + }; + }, []); + + const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false); + const [liveWorkspacePanelRenderWidth, setLiveWorkspacePanelRenderWidth] = useState(null); + const workspacePanelMaximized = useLayoutStore((s) => s.workspacePanelMaximized); + const setWorkspacePanelMaximized = useLayoutStore((s) => s.setWorkspacePanelMaximized); + const rightDockMode = useLayoutStore((s) => s.rightDockMode); + const setRightDockMode = useLayoutStore((s) => s.setRightDockMode); + const [dockRefreshKey, setDockRefreshKey] = useState(0); + const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0); + const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []); + const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`; + const [projectRevision, setProjectRevision] = useState(0); + const [activeTopicTurns, setActiveTopicTurns] = useState(undefined); + const [composerInsertRequestsByTab, setComposerInsertRequestsByTab] = useState>({}); + const [planRevisionInsertRequest, setPlanRevisionInsertRequest] = useState<{ + tabId: string; + approvalId: string; + request: ComposerInsertRequest; + } | null>(null); + const [workspaceInsertTarget, setWorkspaceInsertTarget] = useState("composer"); + const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal); + const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal); + const [desktopPlatform, setDesktopPlatform] = useState(detectBrowserPlatform); + useWailsResizeFix(desktopPlatform === "windows"); + const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text"); + const [statusBarItems, setStatusBarItems] = useState(() => [...DEFAULT_STATUS_BAR_ITEMS]); + const [renamingTopicId, setRenamingTopicId] = useState(null); + const [topicTitleDraft, setTopicTitleDraft] = useState(""); + const topicExportOpen = useOverlayStore((s) => s.topicExportOpen); + const setTopicExportOpen = useOverlayStore((s) => s.setTopicExportOpen); + const sidebarSearchOpen = useOverlayStore((s) => s.sidebarSearchOpen); + const setSidebarSearchOpen = useOverlayStore((s) => s.setSidebarSearchOpen); + const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal); + const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal); + const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false); + const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false); + const [clearContextPending, setClearContextPending] = useState(false); + const topicRenameSkipCommitRef = useRef(false); + const prevDecisionSurfaceRef = useRef(null); + const decisionSurfaceRef = useRef(null); + const topicRenameCommitHandledRef = useRef(false); + const appRef = useRef(null); + const layoutRef = useRef(null); + const sidebarTogglePressTimerRef = useRef(null); + const workspaceTogglePressTimerRef = useRef(null); + + // Persist window geometry across launches. + useWindowStatePersistence(); + useViewportHeightVar(); + useEffect(() => { + document.documentElement.setAttribute("data-platform", desktopPlatform); + }, [desktopPlatform]); + + const closeTransientOverlays = useCallback(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }, []); + + const reloadSidebarImConnections = useCallback(async () => { + const [settings, runtimeStatus] = await Promise.all([ + app.DesktopStartupSettings(), + loadBotRuntimeStatus(), + ]); + setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); + setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); + }, [t]); + + const refreshSidebarImConnectionsFromSettings = useCallback(async (settings: Pick) => { + const runtimeStatus = await loadBotRuntimeStatus(); + setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); + setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); + }, [t]); + + const openBotSettings = useCallback(() => { + closeTransientOverlays(); + setSidebarImDetailConnectionId(""); + setSettingsFocus(null); + setSettingsTarget("bots"); + }, [closeTransientOverlays]); + + const openBotAllowlistSettings = useCallback((connectionId: string) => { + closeTransientOverlays(); + setSidebarImDetailConnectionId(""); + setSettingsFocus({ target: "bot-allowlist", connectionId }); + setSettingsTarget("bots"); + }, [closeTransientOverlays]); + + const pulseSidebarToggle = useCallback(() => { + if (typeof window === "undefined") return; + if (sidebarTogglePressTimerRef.current !== null) { + window.clearTimeout(sidebarTogglePressTimerRef.current); + } + setSidebarTogglePressed(true); + sidebarTogglePressTimerRef.current = window.setTimeout(() => { + sidebarTogglePressTimerRef.current = null; + setSidebarTogglePressed(false); + }, 260); + }, []); + + const pulseWorkspaceToggle = useCallback(() => { + if (typeof window === "undefined") return; + if (workspaceTogglePressTimerRef.current !== null) { + window.clearTimeout(workspaceTogglePressTimerRef.current); + } + setWorkspaceTogglePressed(true); + workspaceTogglePressTimerRef.current = window.setTimeout(() => { + workspaceTogglePressTimerRef.current = null; + setWorkspaceTogglePressed(false); + }, 260); + }, []); + + const anchorAppScrollToChat = useCallback(() => { + if (typeof window === "undefined") return; + const el = appRef.current; + if (!el) return; + const pin = () => { + el.scrollLeft = 0; + }; + pin(); + window.requestAnimationFrame(pin); + window.setTimeout(pin, 300); + }, []); + + useEffect(() => { + return () => { + if (sidebarTogglePressTimerRef.current !== null) { + window.clearTimeout(sidebarTogglePressTimerRef.current); + } + if (workspaceTogglePressTimerRef.current !== null) { + window.clearTimeout(workspaceTogglePressTimerRef.current); + } + }; + }, []); + + useEffect(() => { + let cancelled = false; + const override = browserPlatformOverride(); + if (override) { + setDesktopPlatform(override); + return () => { + cancelled = true; + }; + } + void app.Platform() + .then((value) => { + if (!cancelled) setDesktopPlatform(normalizeDesktopPlatform(value)); + }) + .catch((e) => { + console.warn("platform probe failed", e); + }); + return () => { + cancelled = true; + }; + }, []); + + const applyDesktopPreferences = useCallback( + (settings: Pick) => { + const nextTheme = normalizeThemePreference(settings.desktopTheme); + const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme); + applyTheme(nextTheme, nextStyle, { persist: false }); + const nextLayoutStyle = normalizeDesktopLayoutStyle(settings.desktopLayoutStyle); + setDesktopLayoutStyle(nextLayoutStyle); + applyLayoutStyleDefaults(nextLayoutStyle); + setLocalePref(normalizeLangPref(settings.desktopLanguage)); + setStartupUpdateChecksEnabled(settings.checkUpdates !== false); + setStatusBarStyle(settings.statusBarStyle === "text" ? "text" : "icon"); + setStatusBarItems(normalizeStatusBarItems(settings.statusBarItems)); + }, + [setLocalePref], + ); + + useEffect(() => { + let cancelled = false; + const syncDesktopPreferences = async () => { + const legacyLanguage = readLegacyLangPref(); + const legacyTheme = readLegacyThemePreference(); + if (legacyLanguage || legacyTheme.hasValue) { + await app.MigrateDesktopPreferences(legacyLanguage, legacyTheme.theme, legacyTheme.style); + clearLegacyLangPref(); + clearLegacyThemePreference(); + } + const [settings, runtimeStatus] = await Promise.all([ + app.DesktopStartupSettings(), + loadBotRuntimeStatus(), + ]); + if (cancelled) return; + applyDesktopPreferences(settings); + hydrateDisplayMode(settings.displayMode); + setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); + setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); + }; + void syncDesktopPreferences().catch((e) => { + console.warn("desktop preferences sync failed", e); + setStartupUpdateChecksEnabled(true); + }); + return () => { + cancelled = true; + }; + }, [applyDesktopPreferences, t]); + + useEffect(() => { + setSidebarImDetailConnectionId((current) => { + if (!current) return ""; + return sidebarImConnections.some((connection) => connection.id === current) ? current : ""; + }); + }, [sidebarImConnections]); + + // Open settings when the native menu item (CmdOrCtrl+,) is activated. + useEffect(() => { + if (typeof window === "undefined" || !window.runtime) return; + return window.runtime.EventsOn("app:open-settings", () => { + closeTransientOverlays(); + setSettingsTarget("general"); + }); + }, [closeTransientOverlays]); + useEffect(() => { + if (typeof window === "undefined") return; + const onResize = () => setViewportWidth(window.innerWidth); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + const [pendingPlanRevisionsByTab, setPendingPlanRevisionsByTab] = useState>({}); + const [invocationMetadataByTab, setInvocationMetadataByTab] = useState>({}); + const pendingPlanRevisionSendingTabsRef = useRef(new Set()); + const [footerHeight, setFooterHeight] = useState(0); + const footerHeightRef = useRef(0); + const footerRef = useRef(null); + const activeTabIdRef = useRef(activeTabId); + const commitThenSendRef = useRef<(tabId: string, displayText: string, submitText?: string, structured?: StructuredInvocationSubmit) => Promise>(async () => {}); + const handleInvocationMetadataChange = useCallback((metadata: InvocationMetadataMap) => { + const sourceTabId = activeTabIdRef.current; + if (!sourceTabId) return; + setInvocationMetadataByTab((current) => { + const previous = current[sourceTabId] ?? {}; + const names = Object.keys(metadata); + if (names.length === Object.keys(previous).length && names.every((name) => ( + previous[name]?.kind === metadata[name]?.kind && previous[name]?.color === metadata[name]?.color + ))) return current; + return { ...current, [sourceTabId]: metadata }; + }); + }, []); + const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive; + const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth; + const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; + const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth; + const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive + ? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH + : RIGHT_DOCK_MIN_RENDER_WIDTH; + const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth; + const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH; + const workspacePanelAvailableWidth = availableWorkspacePanelWidth({ + viewportWidth, + sidebarCollapsed, + sidebarWidth, + chatMinWidth: chatReservedWidth, + resizerWidth: WORKSPACE_RESIZER_WIDTH, + }); + + const resolvedWorkspacePanelWidth = resolveWorkspacePanelWidth({ + open: workspacePanelOpen, + maximized: workspacePanelMaximized, + preferredWidth: preferredWorkspacePanelWidth, + minWidth: workspacePanelMinWidth, + availableWidth: workspacePanelAvailableWidth, + }); + + const storedWorkspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth; + const workspacePanelRenderWidth = liveWorkspacePanelRenderWidth ?? storedWorkspacePanelRenderWidth; + const workspacePanelRenderable = + workspacePanelOpen && (workspacePanelMaximized || workspacePanelRenderWidth >= rightDockMinRenderWidth); + const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized; + const resolveLiveWorkspacePanelRenderWidth = useCallback( + (preferredWidth: number, nextSidebarWidth = sidebarWidth) => + resolveLiveWorkspacePanelWidth({ + viewportWidth, + sidebarCollapsed, + sidebarWidth: nextSidebarWidth, + chatMinWidth: chatReservedWidth, + resizerWidth: WORKSPACE_RESIZER_WIDTH, + open: workspacePanelOpen, + maximized: workspacePanelMaximized, + preferredWidth, + minWidth: workspacePanelMinWidth, + }), + [chatReservedWidth, sidebarCollapsed, sidebarWidth, viewportWidth, workspacePanelMaximized, workspacePanelMinWidth, workspacePanelOpen], + ); + const activeTab = useMemo( + () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), + [activeTabId, tabMetas], + ); + const activePlanRevisionInsertRequest = + planRevisionInsertRequest && + planRevisionInsertRequest.tabId === activeTabId && + planRevisionInsertRequest.approvalId === state.approval?.id + ? planRevisionInsertRequest.request + : null; + const composerInsertRequest = activeTabId ? composerInsertRequestsByTab[activeTabId] ?? null : null; + const prefillSubagentCommand = useCallback((command: string) => { + if (!activeTabId) return; + setComposerInsertRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: Date.now(), text: command, mode: "prefix" }, + })); + }, [activeTabId]); + const composerSessionKey = useMemo(() => { + return composerDraftKeyForTab(activeTab, activeTabId); + }, [activeTab, activeTabId]); + const workspaceScopeKey = [ + activeTabId ?? "", + activeTab?.sessionPath ?? "", + state.meta?.sessionPath ?? "", + state.meta?.cwd ?? "", + state.sessionGen, + workspaceControllerEpoch, + ].join("\u0000"); + const sidebarImDetailConnection = useMemo( + () => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null, + [sidebarImConnections, sidebarImDetailConnectionId], + ); + useEffect(() => { + let cancelled = false; + if (!activeTab?.topicId) { + setActiveTopicTurns(undefined); + return () => { + cancelled = true; + }; + } + void app.ListProjectTree() + .then((tree) => { + if (!cancelled) setActiveTopicTurns(activeTopicTurnsFromTree(asArray(tree), activeTab)); + }) + .catch(() => { + if (!cancelled) setActiveTopicTurns(undefined); + }); + return () => { + cancelled = true; + }; + }, [activeTab?.scope, activeTab?.topicId, activeTab?.workspaceRoot, projectRevision]); + const sessionTurns = useMemo(() => { + const visibleUserTurns = state.items.reduce((count, item) => (item.kind === "user" ? count + 1 : count), 0); + const currentTabTurns = Math.max(state.checkpoints.length, visibleUserTurns); + return currentTabTurns > 0 ? currentTabTurns : activeTopicTurns ?? 0; + }, [activeTopicTurns, state.checkpoints.length, state.items]); + const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr; + const activeComposerProfile = activeTabId ? composerProfilesByTab[activeTabId] : undefined; + const backendActiveComposerProfile = useMemo(() => { + if (state.meta) { + return composerProfileFromMeta( + state.meta, + activeTab ? composerProfileMode(composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode)) : undefined, + activeComposerProfile?.toolApprovalMode, + ); + } + return composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode); + }, [activeComposerProfile?.toolApprovalMode, activeTab, state.meta]); + const composerProfile = activeTabId + ? activeComposerProfile ?? backendActiveComposerProfile + : defaultComposerProfile; + const goal = composerProfile.goal; + const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile); + const toolApprovalMode = composerProfile.toolApprovalMode; + const tokenMode: TokenMode = composerProfile.tokenMode; + const controllerReady = state.meta?.ready === true && !state.backendActivationPending; + // Single footer decision surface. Composer stays mounted underneath and is + // only visually/a11y-hidden so per-session draft caches survive. + const decisionSurface = useMemo((): DecisionSurfaceKind | null => { + if (state.approval) { + return state.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval"; + } + if (state.ask) return "ask"; + if (clearContextPending) return "clear_context"; + return null; + }, [clearContextPending, state.approval, state.ask]); + decisionSurfaceRef.current = decisionSurface; + useEffect(() => { + // Close composer menus/popovers when a decision takes over the footer. + if (decisionSurface) { + closeTransientOverlays(); + prevDecisionSurfaceRef.current = decisionSurface; + return; + } + // Restore composer focus on the next frame only if the tab did not switch + // and no new decision arrived (remote resolution / rapid consecutive prompts). + const hadDecision = prevDecisionSurfaceRef.current != null; + prevDecisionSurfaceRef.current = null; + if (!hadDecision) return; + const tabAtRelease = activeTabId; + const frame = requestAnimationFrame(() => { + if (decisionSurfaceRef.current != null) return; + if (activeTabIdRef.current !== tabAtRelease) return; + const input = document.getElementById("composer-input") as HTMLTextAreaElement | null; + input?.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [activeTabId, closeTransientOverlays, decisionSurface]); + const patchActiveComposerProfile = useCallback( + (patch: Partial>, pendingFields: ComposerProfileField[]) => { + if (!activeTabId) return; + setComposerProfilesByTab((current) => patchComposerProfile(current, activeTabId, composerProfile, patch, pendingFields)); + }, + [activeTabId, composerProfile], + ); + const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId); + const visibleTabId = activeTabId; + const visibleTabs = useMemo(() => { + const byId = new Map(tabMetas.map((tab) => [tab.id, tab])); + const ordered = tabOrderIds.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); + const missing = tabMetas.filter((tab) => !tabOrderIds.includes(tab.id)); + return [...ordered, ...missing].map((tab) => { + const profile = composerProfilesByTab[tab.id] ?? composerProfileFromTab(tab); + return { + ...tab, + running: tab.id === visibleTabId ? tab.running || state.running : tab.running, + mode: composerProfileMode(profile), + collaborationMode: displayedComposerProfileCollaborationMode(profile), + toolApprovalMode: profile.toolApprovalMode, + tokenMode: profile.tokenMode, + goal: profile.goal, + active: tab.id === visibleTabId, + }; + }); + }, [composerProfilesByTab, state.running, tabMetas, tabOrderIds, visibleTabId]); + + useEffect(() => { + const ids = tabMetas.map((tab) => tab.id); + setTabOrderIds((current) => { + const next = current.filter((id) => ids.includes(id)); + for (const id of ids) { + if (!next.includes(id)) next.push(id); + } + return next.join("\u0000") === current.join("\u0000") ? current : next; + }); + }, [tabMetas]); + + useEffect(() => { + const ids = new Set(tabMetas.map((tab) => tab.id)); + for (const id of Object.keys(yoloRestoreToolApprovalModesRef.current)) { + if (!ids.has(id)) delete yoloRestoreToolApprovalModesRef.current[id]; + } + userPlanModeByTabRef.current = pruneUserPlanModeIntents(userPlanModeByTabRef.current, ids); + setComposerProfilesByTab((current) => hydrateComposerProfilesFromTabs(current, tabMetas)); + }, [tabMetas]); + + useEffect(() => { + if (!renamingTopicId || activeTab?.topicId === renamingTopicId) return; + topicRenameSkipCommitRef.current = false; + topicRenameCommitHandledRef.current = false; + setRenamingTopicId(null); + setTopicTitleDraft(""); + }, [activeTab?.topicId, renamingTopicId]); + + useEffect(() => { + if (!activeTabId || !state.meta) return; + setComposerProfilesByTab((current) => hydrateComposerProfileFromMeta(current, activeTabId, state.meta!)); + }, [activeTabId, state.meta]); + + const syncModeToController = useCallback((m: Mode) => setControllerMode(m), [setControllerMode]); + + useEffect(() => { + void app.SetTrayLocale(locale).catch(() => {}); + }, [locale]); + + // applyMode is the single source of truth for the input mode: it updates the + // local pill and pushes the matching gate state to the controller (plan = read + // only; yolo = auto-approve approval-gated tools while user decisions still wait). + // normal clears both. + const applyMode = useCallback( + (m: Mode) => { + userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, modeHasPlan(m)); + patchActiveComposerProfile(composerProfileWithMode(m), ["collaborationMode", "toolApprovalMode", "goal"]); + void syncModeToController(m); + }, + [activeTabId, patchActiveComposerProfile, syncModeToController], + ); + const applyCollaborationMode = useCallback( + async (m: CollaborationMode): Promise => { + userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, m === "plan"); + if (m === "goal") { + patchActiveComposerProfile({ collaborationMode: "normal", goalDraftMode: true, goal: "" }, ["collaborationMode", "goal"]); + return setControllerCollaborationMode("normal"); + } + patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]); + if (goal.trim()) await clearControllerGoal(); + await setControllerCollaborationMode(m); + }, + [activeTabId, clearControllerGoal, goal, patchActiveComposerProfile, setControllerCollaborationMode], + ); + const applyToolApprovalMode = useCallback( + (m: ToolApprovalMode) => { + if (!activeTabId) return; + if (m === "yolo") { + if (toolApprovalMode !== "yolo") { + yoloRestoreToolApprovalModesRef.current[activeTabId] = restorableToolApprovalMode(toolApprovalMode); + } + } else { + yoloRestoreToolApprovalModesRef.current[activeTabId] = restorableToolApprovalMode(m); + } + patchActiveComposerProfile({ toolApprovalMode: m }, ["toolApprovalMode"]); + void setControllerToolApprovalMode(m); + }, + [activeTabId, patchActiveComposerProfile, setControllerToolApprovalMode, toolApprovalMode], + ); + const toggleYoloApprovalMode = useCallback(() => { + if (!activeTabId) return; + const next = toggleYoloToolApprovalMode( + toolApprovalMode, + yoloRestoreToolApprovalModesRef.current[activeTabId], + ); + if (next.restore) { + yoloRestoreToolApprovalModesRef.current[activeTabId] = next.restore; + } + applyToolApprovalMode(next.mode); + }, [activeTabId, applyToolApprovalMode, toolApprovalMode]); + const applyGoal = useCallback( + (nextGoal: string) => { + userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false); + const trimmed = nextGoal.trim(); + patchActiveComposerProfile({ + collaborationMode: trimmed ? "goal" : "normal", + goalDraftMode: false, + goal: trimmed, + }, ["collaborationMode", "goal"]); + void (trimmed ? setControllerGoal(trimmed) : clearControllerGoal()); + }, + [activeTabId, clearControllerGoal, patchActiveComposerProfile, setControllerGoal], + ); + const applyTokenMode = useCallback( + (m: TokenMode) => { + patchActiveComposerProfile({ tokenMode: m }, ["tokenMode"]); + void setTokenMode(m); + }, + [patchActiveComposerProfile, setTokenMode], + ); + // Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the + // tool-permission axis while preserving the Ask/Auto base mode. + const cycleMode = useCallback(() => { + applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan"); + }, [applyCollaborationMode, collaborationMode]); + + // Switching models rebuilds the controller, which starts in normal mode — so + // re-apply the current mode, or the pill would say plan/YOLO while the fresh + // controller silently uses normal gating. + const switchModel = useCallback( + async (name: string) => { + await setModel(name); + await setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile)); + await setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) await setControllerGoal(goal); + }, + [composerProfile, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, setModel, toolApprovalMode], + ); + + // Startup and workspace/model rebuilds create a fresh controller in normal + // mode. Re-apply the UI mode once the controller is ready, including the case + // where the user picked YOLO while boot was still loading and the legacy + // SetBypass binding was a harmless no-op. + useEffect(() => { + if (!controllerReady) return; + void setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile)); + void setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) void setControllerGoal(goal); + }, [composerProfile, controllerReady, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, toolApprovalMode]); + + // The live task list pinned above the composer comes from the most recent + // successful top-level todo_write result; failed or still-running attempts do + // not advance the canonical panel state. Incomplete lists are always shown so + // a stale local dismissal cannot hide work that still blocks final readiness; + // every new list starts collapsed while its header keeps showing live progress + // and the current task; completed lists can then be dismissed. The dismissal + // key is still based on stable todo content/state so history reloads + // do not resurrect the same finished list under a different event id. The + // batch key ignores status changes so progress within the same task list does + // not look like a brand-new task batch. Dismissal and open state are scoped to + // the active session/topic/tab so different projects and sessions do not hide + // or reopen each other's todo panels. + const todoEntry = useMemo(() => { + for (let i = state.items.length - 1; i >= 0; i--) { + const it = state.items[i]; + if (it.kind === "tool" && it.name === "todo_write" && !it.parentId && it.status === "done" && !it.error) { + return { item: it, index: i }; + } + } + return null; + }, [state.items]); + const todoItem = todoEntry?.item ?? null; + const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]); + const [dismissedTodoKeys, setDismissedTodoKeys] = useState>(loadDismissedTodoKeys); + const todoKey = useMemo(() => todoDismissalKey(todos), [todos]); + const todoBatch = useMemo(() => todoBatchKey(todos), [todos]); + const todoScope = useMemo( + () => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }), + [activeTab, activeTabId, state.meta?.eventChannel], + ); + const dismissedTodo = useMemo( + () => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey), + [dismissedTodoKeys, todoKey, todoScope], + ); + const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]); + const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]); + const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos); + const dismissTodos = useCallback(() => { + if (!scopedTodoKey) return; + setDismissedTodoKeys((current) => { + if (current.has(scopedTodoKey)) return current; + const next = new Set(current); + next.add(scopedTodoKey); + saveDismissedTodoKeys(next); + return next; + }); + }, [scopedTodoKey]); + + const sessionTitle = topicTitle(activeTab); + const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning); + const getSessionMarkdown = useCallback( + () => sessionItemsToMarkdown(sessionTitle, state.items, state.live), + [sessionTitle, state.items, state.live], + ); + const getSessionJson = useCallback( + () => sessionItemsToJson(sessionTitle, state.items, state.live), + [sessionTitle, state.items, state.live], + ); + + useEffect(() => { + if (!topicExportOpen) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Element | null; + if (!target?.closest(".topicbar__export")) setTopicExportOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [topicExportOpen]); + + const exportSession = useCallback( + async (format: "markdown" | "json" | "pdf" | "image") => { + const base = safeFilename(sessionTitle); + setTopicExportOpen(false); + try { + if (format === "json") { + const path = await app.PickExportFile(`${base}.json`, "application/json"); + if (path) await app.SaveExportFile(path, getSessionJson(), false); + } else if (format === "pdf") { + const path = await app.PickExportFile(`${base}.pdf`, "application/pdf"); + if (!path) return; + const { blobToBase64, renderSessionPdfBlob } = await import("./lib/sessionExport"); + const blob = await renderSessionPdfBlob(getSessionMarkdown(), sessionTitle); + await app.SaveExportFile(path, await blobToBase64(blob), true); + } else if (format === "image") { + const path = await app.PickExportFile(`${base}.png`, "image/png"); + if (!path) return; + const { blobToBase64, renderSessionImageBlob } = await import("./lib/sessionExport"); + const blob = await renderSessionImageBlob(getSessionMarkdown()); + await app.SaveExportFile(path, await blobToBase64(blob), true); + } else { + const path = await app.PickExportFile(`${base}.md`, "text/markdown"); + if (path) await app.SaveExportFile(path, getSessionMarkdown(), false); + } + } catch (err) { + console.error("Failed to export session", err); + } + }, + [getSessionJson, getSessionMarkdown, sessionTitle], + ); + + useEffect(() => { + if (!activeTabId || state.running) return; + const text = pendingPlanRevisionsByTab[activeTabId]; + if (!text || pendingPlanRevisionSendingTabsRef.current.has(activeTabId)) return; + pendingPlanRevisionSendingTabsRef.current.add(activeTabId); + void commitThenSendRef.current(activeTabId, text) + .then(() => { + setPendingPlanRevisionsByTab((current) => { + if (current[activeTabId] !== text) return current; + const next = { ...current }; + delete next[activeTabId]; + return next; + }); + }) + .catch((err) => { + console.warn("Failed to submit pending plan revision", err); + }) + .finally(() => { + pendingPlanRevisionSendingTabsRef.current.delete(activeTabId); + }); + }, [activeTabId, pendingPlanRevisionsByTab, state.running]); + + useEffect(() => { + setClearContextPending(false); + setWorkspaceInsertTarget("composer"); + }, [activeTabId]); + + const cancelClearContext = useCallback(() => { + setClearContextPending(false); + }, []); + + const confirmClearContext = useCallback(async () => { + setClearContextPending(false); + try { + await clearSession(); + setDockRefreshKey((v) => v + 1); + notice(t("clearContext.done")); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + notice(msg || t("clearContext.failed"), "warn"); + } + }, [clearSession, notice, t]); + + useEffect(() => { + activeTabIdRef.current = activeTabId; + }, [activeTabId]); + + // handleSend intercepts slash commands that need a desktop-native action before + // they reach the backend: "/model " rebuilds on that model, "/memory" + // opens Settings, and "/clear" shows an in-app confirmation card. Everything else — skills (/init, …), + // custom commands, bare /model and the other read-only management verbs + // (/skill, /hooks, /mcp) — goes straight to Submit, which the controller + // resolves (a turn, or a listing Notice). + const handleSend = useCallback( + async (displayText: string, submitText = displayText, requestedTabId = activeTabId, structured?: StructuredInvocationSubmit) => { + const sourceTabId = requestedTabId || activeTabId; + if (!sourceTabId) throw new Error(t("composer.workspaceStarting")); + const trimmed = displayText.trim(); + // "!" runs a shell command directly, bypassing the model. + if (trimmed.startsWith("!")) { + const cmd = trimmed.slice(1).trim(); + if (!cmd) { + notice("usage: ! (e.g. !ls -la)"); + return; + } + await runShellForTab(sourceTabId, cmd); + return; + } + const model = /^\/model\s+(\S+)$/.exec(trimmed); + if (model) { + void switchModel(model[1]); + return; + } + if (trimmed === "/memory") { + if (activeTabIdRef.current !== sourceTabId) return; + closeTransientOverlays(); + setSettingsTarget("memory"); + return; + } + if (trimmed === "/clear") { + if (activeTabIdRef.current !== sourceTabId) return; + setClearContextPending(true); + return; + } + const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed); + if (goalCommand) { + const arg = (goalCommand[1] ?? "").trim(); + const displayGoal = stripGoalResearchFlags(arg); + if (displayGoal && !["status", "clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) { + if (hasGoalResearchFlag(arg)) { + userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false); + patchActiveComposerProfile({ + collaborationMode: "goal", + goalDraftMode: false, + goal: displayGoal, + }, ["collaborationMode", "goal"]); + } else { + applyGoal(displayGoal); + } + } else if (["clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) { + applyGoal(""); + } + if (!controllerReady) return; + await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim()); + return; + } + if (collaborationMode === "goal" && !goal.trim()) { + if (!controllerReady) return; + applyGoal(trimmed); + await commitThenSendRef.current(sourceTabId, trimmed, `/goal ${submitText.trim()}`); + return; + } + const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed); + if (theme) { + const arg = theme[1]?.toLowerCase(); + if (!arg) { + const cur = getTheme(); + notice(t("settings.themeCurrent", { theme: cur, style: getThemeStyle(cur) })); + return; + } + if (isThemeMode(arg)) { + const next = arg; + const style = getThemeStyle(next); + try { + await app.SetDesktopAppearance(next, style); + applyTheme(next, style); + notice(t("settings.themeChanged", { theme: next, style })); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + if (isThemeStyle(arg)) { + const cur = getTheme(); + try { + await app.SetDesktopAppearance(cur, arg); + applyTheme(cur, arg); + notice(t("settings.themeChanged", { theme: cur, style: arg })); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + notice(t("settings.themeUnknown", { name: arg }), "warn"); + return; + } + if (!controllerReady) return; + await setControllerCollaborationModeForTab(sourceTabId, controllerComposerProfileCollaborationMode(composerProfile)); + await setControllerToolApprovalModeForTab(sourceTabId, toolApprovalMode); + if (goal.trim()) await setControllerGoalForTab(sourceTabId, goal); + await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured); + }, + [activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab, + setControllerCollaborationModeForTab, setControllerGoalForTab, setControllerToolApprovalModeForTab, switchModel, t, toolApprovalMode, showToast], + ); + + const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => { + const sourceTabId = requestedTabId || activeTabId; + if (!sourceTabId) throw new Error(t("composer.workspaceStarting")); + await steerForTab(sourceTabId, text.trim()); + }, [activeTabId, steerForTab, t]); + + const refreshTabMetas = useCallback(async (): Promise => { + const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[])); + setTabMetas(tabs); + return tabs; + }, []); + const seedActiveTabMeta = useCallback((tab: TabMeta): void => { + setTabMetas((current) => { + const seeded = { ...tab, active: true }; + let found = false; + const next = current.map((existing) => { + if (existing.id === tab.id) { + found = true; + return { ...existing, ...seeded }; + } + return existing.active ? { ...existing, active: false } : existing; + }); + return found ? next : [...next, seeded]; + }); + setTabOrderIds((current) => current.includes(tab.id) ? current : [...current, tab.id]); + }, []); + + useEffect(() => { + const unsub = onEvent((e) => { + if (e.kind !== "turn_done") return; + const turnTabId = resolvePlanRestoreTabId(e.tabId, activeTabIdRef.current); + window.setTimeout(() => { + setProjectRevision((value) => value + 1); + refreshTabMetas().then((tabs) => { + if (!turnTabId) return; + const tab = tabs.find((item) => item.id === turnTabId); + const baseProfile = tab ? composerProfileFromTab(tab) : defaultComposerProfile; + if (!shouldRestoreUserPlanModeForProfile(userPlanModeByTabRef.current, turnTabId, baseProfile)) { + if (baseProfile.goal.trim()) { + userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, turnTabId, false); + } + return; + } + setComposerProfilesByTab((current) => patchComposerProfile( + current, + turnTabId, + current[turnTabId] ?? baseProfile, + { collaborationMode: "plan", goalDraftMode: false, goal: "" }, + ["collaborationMode", "goal"], + )); + if (activeTabIdRef.current === turnTabId) { + void setControllerCollaborationMode("plan"); + } + }); + }, 250); + }); + return unsub; + }, [refreshTabMetas, setControllerCollaborationMode]); + + const blankSessionTarget = useCallback(() => { + const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : ""; + const scope = activeWorkspaceRoot ? "project" : "global"; + return { scope, workspaceRoot: activeWorkspaceRoot }; + }, [activeTab?.scope, activeTab?.workspaceRoot]); + + useEffect(() => { + void refreshTabMetas(); + const id = window.setInterval(() => void refreshTabMetas(), 2000); + return () => window.clearInterval(id); + }, [refreshTabMetas]); + + useEffect(() => { + return onProjectTreeChanged(() => { + setProjectRevision((value) => value + 1); + void refreshTabMetas(); + }); + }, [refreshTabMetas]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const needs = await app.NeedsOnboarding(); + if (!cancelled) setNeedsOnboarding(needs); + } catch { + // Bridge unavailable (browser dev seam) — skip the gate; a real key + // failure still surfaces via the topbar startupError banner. + if (!cancelled) setNeedsOnboarding(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const el = footerRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + let frame = 0; + const update = () => { + if (frame) window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + frame = 0; + const next = Math.round(el.getBoundingClientRect().height); + if (Math.abs(footerHeightRef.current - next) < 2) return; + footerHeightRef.current = next; + setFooterHeight(next); + }); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => { + if (frame) window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + + // Run the ambient engine only while the agent is generating. + useEffect(() => { + if (state.running && isGenerativeMusicEnabled()) { + generativeMusic.start(); + } else { + generativeMusic.stop(); + } + return () => generativeMusic.stop(); + }, [state.running]); + + // playTokenNote no-ops unless the engine is running, so subscribe unconditionally. + useEffect(() => { + const unsub = onEvent((e) => { + if (e.kind === "text" || e.kind === "reasoning" || e.kind === "tool_dispatch") { + generativeMusic.playTokenNote(); + } + }); + return unsub; + }, []); + + const toggleSidebar = useCallback(() => { + closeTransientOverlays(); + pulseSidebarToggle(); + anchorAppScrollToChat(); + const nextCollapsed = !sidebarCollapsed; + if (nextCollapsed) setSidebarSearchOpen(false); + setSidebarCollapsed(nextCollapsed); + saveSidebarCollapsed(nextCollapsed); + }, [anchorAppScrollToChat, closeTransientOverlays, pulseSidebarToggle, sidebarCollapsed]); + + const sidebarWidthClamp = desktopLayoutStyle === "creation" ? clampCreationSidebarWidth : clampSidebarWidth; + const sidebarRenderWidth = liveSidebarWidth ?? sidebarWidth; + const sidebarResizeMinWidth = desktopLayoutStyle === "creation" ? CREATION_SIDEBAR_MIN_WIDTH : SIDEBAR_MIN_WIDTH; + + useEffect(() => { + if (desktopLayoutStyle === "creation" || sidebarWidth >= SIDEBAR_MIN_WIDTH) return; + setSidebarWidth(SIDEBAR_MIN_WIDTH); + saveSidebarWidth(SIDEBAR_MIN_WIDTH); + }, [desktopLayoutStyle, sidebarWidth]); + + useEffect(() => { + if (desktopLayoutStyle === "creation") { + if (rightDockTreeWidth >= CREATION_RIGHT_DOCK_TREE_MIN_WIDTH) return; + setRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH); + saveRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH); + return; + } + if (rightDockTreeWidth >= RIGHT_DOCK_TREE_MIN_WIDTH) return; + setRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH); + saveRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH); + }, [desktopLayoutStyle, rightDockTreeWidth]); + + // Creation no longer exposes the overview tab. If a previous session left + // rightDockMode on "context", coerce it to files so 文件 stays selected. + useEffect(() => { + if (desktopLayoutStyle !== "creation") return; + if (rightDockMode !== "context") return; + setRightDockMode("files"); + }, [desktopLayoutStyle, rightDockMode, setRightDockMode]); + + const setExpandedSidebarWidth = useCallback((width: number) => { + closeTransientOverlays(); + const next = sidebarWidthClamp(width); + setSidebarWidth(next); + saveSidebarWidth(next); + }, [closeTransientOverlays, sidebarWidthClamp]); + + const startSidebarResize = useCallback( + (event: ReactPointerEvent) => { + if (sidebarCollapsed) return; + const layout = layoutRef.current; + if (!layout) return; + event.preventDefault(); + closeTransientOverlays(); + setSidebarResizing(true); + let nextWidth = sidebarWidth; + const liveResize = createRafResizeUpdater({ + target: layout, + separator: event.currentTarget, + cssVar: "--sidebar-expanded-width", + onApply: setLiveSidebarWidth, + }); + const dockLiveResize = createRafResizeUpdater({ + target: layout, + cssVar: "--workspace-width", + onApply: setLiveWorkspacePanelRenderWidth, + }); + const onMove = (moveEvent: PointerEvent) => { + nextWidth = sidebarWidthClamp(moveEvent.clientX); + liveResize.schedule(nextWidth); + dockLiveResize.schedule(resolveLiveWorkspacePanelRenderWidth(preferredWorkspacePanelWidth, nextWidth)); + }; + const onDone = () => { + liveResize.flush(); + dockLiveResize.flush(); + setSidebarWidth(nextWidth); + saveSidebarWidth(nextWidth); + setLiveSidebarWidth(null); + setLiveWorkspacePanelRenderWidth(null); + setSidebarResizing(false); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onDone); + window.removeEventListener("pointercancel", onDone); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onDone); + window.addEventListener("pointercancel", onDone); + }, + [closeTransientOverlays, preferredWorkspacePanelWidth, resolveLiveWorkspacePanelRenderWidth, sidebarCollapsed, sidebarWidth, sidebarWidthClamp], + ); + + const resizeSidebarWithKeyboard = useCallback( + (event: KeyboardEvent) => { + if (sidebarCollapsed) return; + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + setExpandedSidebarWidth(sidebarWidth + (event.key === "ArrowRight" ? 16 : -16)); + } else if (event.key === "Home") { + event.preventDefault(); + setExpandedSidebarWidth(sidebarResizeMinWidth); + } else if (event.key === "End") { + event.preventDefault(); + setExpandedSidebarWidth(SIDEBAR_MAX_WIDTH); + } + }, + [setExpandedSidebarWidth, sidebarCollapsed, sidebarWidth, sidebarResizeMinWidth], + ); + + const setSavedWorkspacePanelWidth = useCallback( + (width: number) => { + closeTransientOverlays(); + if (rightDockDetailActive) { + const next = clampRightDockPreviewWidth(width); + setRightDockPreviewWidth(next); + saveRightDockPreviewWidth(next); + return; + } + const next = rightDockTreeWidthClamp(width); + setRightDockTreeWidth(next); + saveRightDockTreeWidth(next); + }, + [closeTransientOverlays, rightDockDetailActive, rightDockTreeWidthClamp], + ); + + const ensureWorkspacePanelWidth = useCallback( + (width: number) => { + closeTransientOverlays(); + if (rightDockMode === "context") return; + const next = clampRightDockPreviewWidth(width); + setRightDockPreviewWidth(next); + saveRightDockPreviewWidth(next); + }, + [closeTransientOverlays, rightDockMode], + ); + + const startWorkspacePanelResize = useCallback( + (event: ReactPointerEvent) => { + if (!workspacePanelOpen) return; + const layout = layoutRef.current; + if (!layout) return; + event.preventDefault(); + closeTransientOverlays(); + setWorkspacePanelResizing(true); + const startX = event.clientX; + const startDockWidth = workspacePanelRenderWidth; + let nextDockWidth = startDockWidth; + const liveResize = createRafResizeUpdater({ + target: layout, + separator: event.currentTarget, + cssVar: "--workspace-width", + onApply: setLiveWorkspacePanelRenderWidth, + }); + const onMove = (moveEvent: PointerEvent) => { + const delta = moveEvent.clientX - startX; + nextDockWidth = startDockWidth - delta; + if (rightDockDetailActive) { + nextDockWidth = clampRightDockPreviewWidth(nextDockWidth); + } else { + nextDockWidth = rightDockTreeWidthClamp(nextDockWidth); + } + liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth)); + }; + const onDone = () => { + liveResize.flush(); + setSavedWorkspacePanelWidth(nextDockWidth); + setLiveWorkspacePanelRenderWidth(null); + setWorkspacePanelResizing(false); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onDone); + window.removeEventListener("pointercancel", onDone); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onDone); + window.addEventListener("pointercancel", onDone); + }, + [closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePanelRenderWidth], + ); + + const resizeWorkspacePanelWithKeyboard = useCallback( + (event: KeyboardEvent) => { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16)); + } else if (event.key === "Home") { + event.preventDefault(); + setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth); + } else if (event.key === "End") { + event.preventDefault(); + setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH); + } + }, + [rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelRenderWidth], + ); + + const openWorkspacePanel = useCallback( + (mode: RightDockMode = rightDockMode) => { + closeTransientOverlays(); + if (mode === "context" || mode !== rightDockMode) { + setWorkspacePreviewActive(false); + } + setRightDockMode(mode); + let nextMaximized = workspacePanelMaximized; + if (mode === "context") { + nextMaximized = false; + setWorkspacePanelMaximized(false); + } else { + // Keep file/change views docked; the rendered dock width is clamped to + // the viewport so opening it reflows instead of forcing maximize. + nextMaximized = false; + setWorkspacePanelMaximized(false); + } + if (workspacePanelOpen && workspacePanelMaximized === nextMaximized) { + return; + } + setWorkspacePanelOpen(true); + }, + [closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen], + ); + + const closeWorkspacePanel = useCallback(() => { + closeTransientOverlays(); + if (!workspacePanelOpen) { + return; + } + setLiveWorkspacePanelRenderWidth(null); + setWorkspacePanelMaximized(false); + setWorkspacePanelOpen(false); + }, [closeTransientOverlays, workspacePanelOpen]); + + const toggleWorkspacePanel = useCallback(() => { + pulseWorkspaceToggle(); + if (workspacePanelRenderable) { + closeWorkspacePanel(); + return; + } + // Creation hides the overview tab; never reopen into the invisible "context" + // mode or neither 文件/改动 will show an active selection. + if (desktopLayoutStyle === "creation") { + openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files"); + return; + } + openWorkspacePanel("context"); + }, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, pulseWorkspaceToggle, rightDockMode, workspacePanelRenderable]); + + const openRightDockMode = useCallback( + (mode: RightDockMode) => { + openWorkspacePanel(mode); + }, + [openWorkspacePanel], + ); + + const handleWorkspacePreviewModeChange = useCallback( + (active: boolean) => { + if (workspacePreviewActive === active) return; + closeTransientOverlays(); + setWorkspacePreviewActive(active); + }, + [closeTransientOverlays, workspacePreviewActive], + ); + + const layoutStyle = useMemo( + () => + ({ + "--sidebar-expanded-width": `${sidebarRenderWidth}px`, + "--chat-min-width": `${chatReservedWidth}px`, + "--workspace-width": `${workspacePanelRenderWidth}px`, + "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, + }) as CSSProperties, + [chatReservedWidth, sidebarRenderWidth, workspacePanelRenderWidth], + ); + + const setWorkspacePanel = useCallback((open: boolean) => { + if (open) { + openWorkspacePanel(); + } else { + closeWorkspacePanel(); + } + }, [closeWorkspacePanel, openWorkspacePanel]); + + const addWorkspaceTextToComposer = useCallback((text: string) => { + if (activeTabId && workspaceInsertTarget === "planRevision" && state.approval?.tool === "exit_plan_mode") { + setPlanRevisionInsertRequest({ + tabId: activeTabId, + approvalId: state.approval.id, + request: { id: Date.now(), text }, + }); + return; + } + if (activeTabId) { + setComposerInsertRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: Date.now(), text }, + })); + } + }, [activeTabId, state.approval, workspaceInsertTarget]); + + // Coalesce tab-bar switches through the same last-click-wins scheduler that + // openTopic/blank/resume navigation uses, so rapidly clicking between two + // running sessions can't run two switchTab() calls concurrently. Concurrent + // switches race on the backend SetActiveTab/confirmBackendActiveTab ordering, + // which lands events + hydration on the wrong session (#5352). switchTab's own + // loadSessionDataForTab is already seq-guarded; this serializes the backend + // activation around it. + const tabSwitchSeqRef = useRef(0); + const tabSwitchRunningRef = useRef(false); + const tabSwitchPendingRef = useRef | null>(null); + const enqueueTabSwitch = useCallback( + (tabId: string, optimisticTab?: TabMeta): Promise => + enqueueNavigationRequest( + { seqRef: tabSwitchSeqRef, runningRef: tabSwitchRunningRef, pendingRef: tabSwitchPendingRef }, + { tabId, optimisticTab }, + async (request) => { + await switchTab(request.tabId, request.optimisticTab); + await refreshTabMetas(); + }, + ), + [switchTab, refreshTabMetas], + ); + + const handleTabChange = useCallback((id: string) => { + closeTransientOverlays(); + const selected = tabMetas.find((tab) => tab.id === id); + setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === id }))); + void enqueueTabSwitch(id, selected); + setTabRevealSignal((signal) => signal + 1); + }, [closeTransientOverlays, enqueueTabSwitch, tabMetas]); + + const handleTabClose = useCallback(async (id: string) => { + closeTransientOverlays(); + setComposerProfilesByTab((current) => { + if (!(id in current)) return current; + const next = { ...current }; + delete next[id]; + return next; + }); + setTabMetas((current) => { + if (current.length <= 1) return current; + const closingIndex = current.findIndex((tab) => tab.id === id); + if (closingIndex < 0) return current; + const closingTab = current[closingIndex]; + const remaining = current.filter((tab) => tab.id !== id); + if (!closingTab.active && closingTab.id !== activeTabId) return remaining; + const nextIndex = Math.min(closingIndex, remaining.length - 1); + const nextActiveId = remaining[nextIndex]?.id; + return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId })); + }); + await closeTab(id); + await refreshTabMetas(); + setTabRevealSignal((signal) => signal + 1); + }, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]); + + const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => { + closeTransientOverlays(); + const currentIds = tabMetas.map((tab) => tab.id); + const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index); + if (targets.length === 0) return; + for (const id of targets) { + await closeTab(id); + } + if (nextActiveTabId && currentIds.includes(nextActiveTabId)) { + const selected = tabMetas.find((tab) => tab.id === nextActiveTabId); + setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === nextActiveTabId }))); + void enqueueTabSwitch(nextActiveTabId, selected); + } + await refreshTabMetas(); + setTabRevealSignal((signal) => signal + 1); + }, [closeTab, closeTransientOverlays, enqueueTabSwitch, refreshTabMetas, tabMetas]); + + const handleTabsReorder = useCallback(async (ids: string[]) => { + setTabOrderIds(ids); + setTabMetas((current) => { + const byId = new Map(current.map((tab) => [tab.id, tab])); + const ordered = ids.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); + return ordered.length === current.length ? ordered : current; + }); + await reorderTabs(ids); + await refreshTabMetas(); + setTabRevealSignal((signal) => signal + 1); + }, [refreshTabMetas, reorderTabs]); + + const [rewindSignal, setRewindSignal] = useState(0); + + // ── Optimistic rewind ───────────────────────────────────────────────── + // Rewind is optimistic: the UI immediately truncates, scrolls to the + // target, fills the composer, and shows an undo banner. The real Go + // Rewind is deferred until the user SENDS a new message. Undo simply + // restores the full items list — no Go call needed. + type RewindState = { + turn: number; + scope: string; + fullItems: Item[]; // pre-truncation items (for undo) + boundaryIdx: number; // first item index of the rewound-to turn + turnDiff: number; // turns rolled back + prompt: string; // user message text for composer fill + }; + const [rewindStatesByTab, setRewindStatesByTab] = useState>({}); + const rewindStatesByTabRef = useRef(rewindStatesByTab); + rewindStatesByTabRef.current = rewindStatesByTab; + const [rewindCommittingByTab, setRewindCommittingByTab] = useState>({}); + const rewindState = activeTabId ? rewindStatesByTab[activeTabId] ?? null : null; + const rewindCommitting = Boolean(activeTabId && rewindCommittingByTab[activeTabId]); + + const setRewindStateForTab = useCallback((tabId: string, nextState: RewindState | null) => { + if (!tabId) return; + const next = { ...rewindStatesByTabRef.current }; + if (nextState) next[tabId] = nextState; + else delete next[tabId]; + rewindStatesByTabRef.current = next; + setRewindStatesByTab(next); + }, []); + + const setRewindCommittingForTab = useCallback((tabId: string, committing: boolean) => { + setRewindCommittingByTab((current) => { + const next = { ...current }; + if (committing) next[tabId] = true; + else delete next[tabId]; + return next; + }); + }, []); + + const hydratePlaceholderActive = Boolean( + state.hydrating && + state.items.length === 0 && + state.hydratePlaceholderItems?.length, + ); + const transcriptHydrating = state.hydrating && !state.hydrateHistoryLoaded; + const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items; + + // Display items: truncated when an optimistic rewind is pending. + const displayItems = useMemo(() => { + if (!rewindState) return transcriptItems; + return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction"); + }, [transcriptItems, rewindState]); + const latestGuidanceConsumed = useMemo(() => { + for (let i = state.items.length - 1; i >= 0; i--) { + const item = state.items[i]; + if (item.kind === "notice" && item.text.startsWith("↪ ")) { + return { key: item.id, text: item.text.slice(2) }; + } + } + return null; + }, [state.items]); + + // send wrapper: commits any pending optimistic rewind before sending. + const commitThenSend = useCallback(async (sourceTabId: string, displayText: string, submitText?: string, structured?: StructuredInvocationSubmit) => { + const sourceTab = tabMetas.find((tab) => tab.id === sourceTabId); + if (!sourceTab) throw new Error(t("composer.workspaceStarting")); + if (sourceTab.readOnly) throw new Error(t("composer.readOnlyChannel")); + if (sourceTab.ready === false) throw new Error(t("composer.workspaceStarting")); + const rs = rewindStatesByTabRef.current[sourceTabId]; + if (rs) { + setRewindStateForTab(sourceTabId, null); + setRewindCommittingForTab(sourceTabId, true); + let ok = false; + try { + ok = await rewindForTab(sourceTabId, rs.turn, rs.scope); + } finally { + setRewindCommittingForTab(sourceTabId, false); + } + if (!ok) { + // Rewind failed: the Go conversation is intact. Do not send; the + // controller emits a notice with the reason. + setRewindStateForTab(sourceTabId, rs); + throw new Error(t("rewind.failed")); + } + setRewindSignal((v) => v + 1); + if (rs.scope === "both") { + // Code was only reverted now (deferred), so refresh the dock here. + setDockRefreshKey((v) => v + 1); + setProjectRevision((v) => v + 1); + } + } + await sendToTab(sourceTabId, displayText, submitText, undefined, structured); + }, [rewindForTab, sendToTab, setRewindCommittingForTab, setRewindStateForTab, t, tabMetas]); + + const handleTranscriptPrompt = useCallback((text: string) => { + if (!activeTabId || !controllerReady) return; + void commitThenSend(activeTabId, text).catch((err) => { + console.warn("Failed to submit transcript prompt", err); + }); + }, [activeTabId, commitThenSend, controllerReady]); + commitThenSendRef.current = commitThenSend; + + const handleMessageAction = useCallback((turn: number, scope: string) => { + const sourceTabId = activeTabId; + if (!sourceTabId || activeTab?.readOnly) return; + if (hydratePlaceholderActive) return; + if (scope === "fork") { + // Fork still goes through the controller (not optimistic). + rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + refreshTabMetas(); + setProjectRevision((v) => v + 1); + }); + return; + } + + // Code-only rewind only affects files — no message truncation, + // no optimistic UI needed. Execute immediately. + if (scope === "code") { + rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + setDockRefreshKey((v) => v + 1); + setProjectRevision((v) => v + 1); + }); + return; + } + + // Summarize only compresses the conversation log — no files touched, + // no optimistic UI needed. Execute immediately like code-only rewind. + if (scope === "summ-from" || scope === "summ-upto") { + rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + setDockRefreshKey((v) => v + 1); + setProjectRevision((v) => v + 1); + }); + return; + } + + const items = state.items; + const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null); + let boundaryIdx = -1; + let userCount = 0; + let targetUserCount = -1; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === "user") { + const item = items[i] as Extract; + const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn; + if (matches) { + boundaryIdx = i; + targetUserCount = userCount; + break; + } + userCount++; + } + } + if (boundaryIdx < 0) { + rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + if (scope === "both") { + setDockRefreshKey((v) => v + 1); + setProjectRevision((v) => v + 1); + } + }); + return; + } + + const prevUserCount = items.filter((it) => it.kind === "user").length; + const turnDiff = prevUserCount - targetUserCount; + + // Save full items for undo. + const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract : undefined; + const prompt = userItem?.text ?? ""; + setRewindStateForTab(sourceTabId, { + turn, + scope, + fullItems: items, + boundaryIdx, + turnDiff, + prompt, + }); + + // Fill composer with the rewound-to user message. + const insertId = Date.now(); + setComposerInsertRequestsByTab((current) => ({ + ...current, + [sourceTabId]: { id: insertId, text: prompt, mode: "replace" }, + })); + + setRewindSignal((v) => v + 1); + }, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, refreshTabMetas, setRewindStateForTab]); + + const handleEditPrompt = useCallback(async (turn: number, displayText: string, submitText?: string): Promise => { + const sourceTabId = activeTabId; + if (!sourceTabId || activeTab?.readOnly || !controllerReady || hydratePlaceholderActive || rewindStatesByTabRef.current[sourceTabId] || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending) return false; + const next = displayText.trim(); + if (!next) return false; + const submit = (submitText ?? displayText).trim(); + const hasCheckpointTurns = state.items.some((it) => it.kind === "user" && it.checkpointTurn != null); + let original = ""; + let userCount = 0; + for (const item of state.items) { + if (item.kind !== "user") continue; + const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn; + if (matches) { + original = (item.submitText ?? item.text).trim(); + break; + } + userCount++; + } + const ok = await rewindForTab(sourceTabId, turn, "conversation"); + if (!ok) return false; + setRewindSignal((v) => v + 1); + try { + await sendToTab(sourceTabId, next, submit, original); + return true; + } catch { + return false; + } + }, [activeTab?.readOnly, activeTabId, clearContextPending, controllerReady, hydratePlaceholderActive, sendToTab, state.approval, state.ask, state.items, state.messageAction, state.running, rewindForTab]); + + // History drawer: project menus can open a scoped saved-session list. Idle row + // clicks resume; running row clicks only preview through PreviewSession. + const openProjectHistory = useCallback(async (scope: "global" | "project", workspaceRoot: string) => { + closeTransientOverlays(); + const filter = { scope, workspaceRoot }; + setHistView({ kind: "history", source: "scope", filter, sessions: sessionsForScope(await listSessions(), filter) }); + }, [closeTransientOverlays, listSessions]); + const openAllHistory = useCallback(async () => { + closeTransientOverlays(); + setHistView({ kind: "history", source: "all", sessions: await listSessions() }); + }, [closeTransientOverlays, listSessions]); + const openTrash = useCallback(async () => { + closeTransientOverlays(); + setHistView({ kind: "trash", sessions: await listTrashedSessions() }); + }, [closeTransientOverlays, listTrashedSessions]); + const closeHistory = useCallback(() => { + closeTransientOverlays(); + setHistView(null); + }, [closeTransientOverlays]); + const refreshHistoryView = useCallback(async () => { + const sessions = await listSessions().catch(() => null); + if (!sessions) return; + setHistView((cur) => + cur === null || cur.kind !== "history" + ? cur + : cur.source === "scope" + ? { ...cur, sessions: sessionsForScope(sessions, cur.filter) } + : { ...cur, sessions }, + ); + }, [listSessions]); + + const navigationSeqRef = useRef(0); + const navigationRunningRef = useRef(false); + const navigationPendingRef = useRef(null); + const runNavigationRequest = useCallback(async (request: PendingDesktopNavigationRequest) => { + const latest = () => request.seq === navigationSeqRef.current; + const refreshLatestTabMetas = async (): Promise => { + const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[])); + if (latest()) setTabMetas(tabs); + return tabs; + }; + const openTopicTarget = async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise => { + if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || ""); + if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath); + if (scope === "global") return openGlobalTab(topicId); + return openProjectTab(workspaceRoot, topicId); + }; + const openBlankTarget = async (scope: string, workspaceRoot: string): Promise => { + const root = scope === "project" ? workspaceRoot : ""; + return singleSurfaceLayout ? ensureBlankSurface(scope, root) : ensureBlankTab(scope, root); + }; + + try { + if (request.kind === "topic") { + const openedTab = await openTopicTarget(request.scope, request.workspaceRoot, request.topicId, request.sessionPath); + if (!latest()) return; + seedActiveTabMeta(openedTab); + void refreshLatestTabMetas(); + setTabRevealSignal((signal) => signal + 1); + setTranscriptRevealSignal((signal) => signal + 1); + return; + } + + if (request.kind === "blank") { + const openedTab = await openBlankTarget(request.scope, request.workspaceRoot); + if (!latest()) return; + seedActiveTabMeta(openedTab); + setProjectRevision((value) => value + 1); + await refreshLatestTabMetas(); + if (!latest()) return; + setTabRevealSignal((signal) => signal + 1); + setTranscriptRevealSignal((signal) => signal + 1); + return; + } + + if (request.kind === "sidebar-im") { + const { connection } = request; + const target = sidebarImSessionTarget(connection); + if (!target) { + if (latest()) showToast(t("sidebar.imWaiting", { name: connection.title })); + return; + } + let openedTab: TabMeta | undefined; + if (connection.sessionSource === "auto" && target.kind === "path") { + openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot); + if (!latest()) return; + await openChannelSession(target.value, openedTab.id); + } else if (target.kind === "path") { + openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot); + if (!latest()) return; + await resumeSession(target.value, openedTab.id); + } else { + openedTab = await openTopicTarget(connection.scope, connection.workspaceRoot, target.value); + } + if (!latest()) return; + if (openedTab) seedActiveTabMeta(openedTab); + await refreshLatestTabMetas(); + if (!latest()) return; + setTabRevealSignal((value) => value + 1); + setTranscriptRevealSignal((value) => value + 1); + setProjectRevision((value) => value + 1); + return; + } + + const { session } = request; + const scope = session.scope || (session.workspaceRoot ? "project" : "global"); + let targetTab: TabMeta; + if (isChannelSession(session)) { + targetTab = await openBlankTarget(scope === "project" ? "project" : "global", scope === "project" ? session.workspaceRoot || "" : ""); + if (!latest()) return; + await openChannelSession(session.path, targetTab.id); + } else if (scope === "project" && session.workspaceRoot && session.topicId) { + targetTab = await openTopicTarget("project", session.workspaceRoot, session.topicId, session.path); + } else if (scope === "global" && session.topicId) { + targetTab = await openTopicTarget("global", "", session.topicId, session.path); + } else { + throw new Error(scope === "global" && !session.topicId + ? t("history.failedOpenSession") + : (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession"))); + } + if (!latest()) return; + seedActiveTabMeta(targetTab); + setHistView(null); + void refreshLatestTabMetas(); + setTabRevealSignal((value) => value + 1); + setTranscriptRevealSignal((value) => value + 1); + } catch (err: any) { + if (!latest()) return; + if (request.kind === "topic" || request.kind === "blank") { + console.warn("desktop navigation failed", err); + showToast(t("history.failedOpenSession"), "error"); + void refreshLatestTabMetas(); + return; + } + if (request.kind === "sidebar-im") { + console.warn("bot sidebar open failed", err); + showToast(t("sidebar.imOpenFailed", { name: request.connection.title })); + return; + } + await refreshHistoryView(); + if (!latest() || isMissingSessionError(err)) return; + setHistView(null); + const session = request.session; + const scope = session.scope || (session.workspaceRoot ? "project" : "global"); + if (scope === "project" && session.workspaceRoot) { + const name = workspaceDisplayName(session.workspaceRoot); + showToast(t("history.failedOpenProject", { name, path: session.workspaceRoot })); + } else { + showToast(err?.message || String(err)); + } + } + }, [activateTopic, ensureBlankSurface, ensureBlankTab, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]); + + const enqueueNavigation = useCallback((input: DesktopNavigationInput): Promise => enqueueNavigationRequest( + { seqRef: navigationSeqRef, runningRef: navigationRunningRef, pendingRef: navigationPendingRef }, + input, + runNavigationRequest, + ), [runNavigationRequest]); + + const openBlankSession = useCallback((scope: string, workspaceRoot: string): Promise => + enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }), + [enqueueNavigation]); + + useEffect(() => onSessionRecovered(() => { + setProjectRevision((value) => value + 1); + void refreshTabMetas(); + }), [refreshTabMetas]); + + const handleNewTab = useCallback(async () => { + closeTransientOverlays(); + setSidebarImDetailConnectionId(""); + const target = blankSessionTarget(); + await openBlankSession(target.scope, target.workspaceRoot); + }, [blankSessionTarget, closeTransientOverlays, openBlankSession]); + + const handleOpenTopic = useCallback((scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise => { + closeTransientOverlays(); + setSidebarImDetailConnectionId(""); + return enqueueNavigation({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }); + }, [closeTransientOverlays, enqueueNavigation]); + + const openSidebarImConnectionSession = useCallback((connection: SidebarImConnection): Promise => { + setSidebarImDetailConnectionId(""); + return enqueueNavigation({ kind: "sidebar-im", connection }); + }, [enqueueNavigation]); + + const onResumeSession = useCallback((session: SessionMeta): Promise => { + if (state.running && !singleSurfaceLayout) return Promise.resolve(); + return enqueueNavigation({ kind: "resume-session", session }); + }, [enqueueNavigation, singleSurfaceLayout, state.running]); + + // Command palette: ⌘K / Ctrl+K opens a fuzzy navigator over commands and + // recent sessions. Sessions are snapshotted on open so the list is stable + // while the palette is up. + const openPalette = useCallback(async () => { + closeTransientOverlays(); + setPaletteOpen(true); + setPaletteSessions(await listSessions().catch(() => [])); + }, [closeTransientOverlays, listSessions]); + useGlobalShortcut("commandPalette.open", () => { + setPaletteOpen((current) => { + if (!current) void openPalette(); + return !current; // ← fix: toggle the state so the palette actually opens/closes + }); + }, [openPalette]); + useGlobalShortcut("app.newSession", () => void handleNewTab(), [handleNewTab]); + useGlobalShortcut("settings.open", () => { + closeTransientOverlays(); + setSettingsTarget("general"); + }, [closeTransientOverlays]); + useGlobalShortcut("tab.close", () => { + if (activeTabId) void handleTabClose(activeTabId); + }, [activeTabId, handleTabClose], Boolean(activeTabId)); + useGlobalShortcut("shortcuts.show", () => setShortcutsOpen(true)); + useGlobalShortcut("sidebar.toggle", toggleSidebar, [toggleSidebar]); + + // --- Topic shortcut navigation (Cmd/Ctrl+1-9) --- + const visibleTopicsRef = useRef([]); + const handleVisibleTopicsChange = useCallback((topics: TopicShortcutEntry[]) => { + visibleTopicsRef.current = topics; + }, []); + const handleNavigateTopic = useCallback((entry: TopicShortcutEntry) => { + void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath); + }, [handleOpenTopic]); + const { showBadges: showTopicBadges } = useTopicShortcuts(!sidebarCollapsed, desktopPlatform); + + // Register Cmd/Ctrl+1-9 shortcuts for topic navigation + useEffect(() => { + if (sidebarCollapsed) return; + const onKeydown = (event: globalThis.KeyboardEvent) => { + const idx = topicShortcutIndexFromEvent(event, desktopPlatform); + if (idx === null) return; + event.preventDefault(); + const topics = visibleTopicsRef.current; + if (idx < topics.length) { + handleNavigateTopic(topics[idx]); + } + }; + document.addEventListener("keydown", onKeydown); + return () => document.removeEventListener("keydown", onKeydown); + }, [sidebarCollapsed, desktopPlatform, handleNavigateTopic]); + + const paletteItems = useMemo(() => { + const cmds: PaletteItem[] = [ + { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: , compact: true, keywords: ["new", "新建"], run: () => void handleNewTab() }, + { id: "cmd-history", group: t("palette.group.commands"), title: t("palette.cmd.history"), icon: , compact: true, keywords: ["history", "历史"], run: () => void openAllHistory() }, + { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: , compact: true, keywords: ["trash", "回收站"], run: () => void openTrash() }, + { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: , compact: true, keywords: ["settings", "设置"], run: () => setSettingsTarget("general") }, + { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: , compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => setSettingsTarget("appearance") }, + { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: , compact: true, keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") }, + { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: , compact: true, keywords: ["model", "模型"], run: () => setSettingsTarget("models") }, + ]; + const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const dayLabel = (ms: number) => { + const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000); + if (days <= 0) return t("history.today"); + if (days === 1) return t("history.yesterday"); + return new Date(ms).toLocaleDateString(); + }; + const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({ + id: `sess-${s.path}`, + group: t("palette.group.sessions"), + title: paletteSessionDisplayTitle(s, t("history.emptySession")), + hint: paletteSessionHint(s), + keywords: paletteSessionKeywords(s), + meta: dayLabel(sessionActivityTime(s)), + badge: t(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }), + run: () => void onResumeSession(s), + })); + return [...cmds, ...sessionItems]; + }, [t, paletteSessions, handleNewTab, openAllHistory, openTrash, onResumeSession]); + // Delete / rename act on disk, then re-fetch so the panel reflects the change. + const onDeleteSession = useCallback( + async (path: string) => { + if (state.running) return; + try { + await deleteSession(path); + } catch { + await refreshHistoryView(); + return; + } + // Local state removal: filter the deleted session out of the current + // history view instead of re-fetching the full list from the backend. + setHistView((cur) => + cur === null || cur.kind !== "history" + ? cur + : { ...cur, sessions: cur.sessions.filter((s) => s.path !== path) }, + ); + }, + [state.running, deleteSession, refreshHistoryView], + ); + const onDeleteManySessions = useCallback( + async (paths: string[]) => { + if (state.running) return; + const uniquePaths = Array.from(new Set(paths)); + for (const path of uniquePaths) { + // Best effort per path: one locked/missing file must not abandon the + // rest of the sweep. The guarded backend method revalidates actual + // branch and parent content before moving anything. + await app.DeleteRecoveryCopy(path).catch(() => undefined); + } + await refreshHistoryView(); + }, + [state.running, refreshHistoryView], + ); + const onRenameSession = useCallback( + async (path: string, title: string) => { + if (state.running) return; + await renameSession(path, title); + const sessions = await listSessions(); + setHistView((cur) => + cur === null + ? null + : cur.kind === "history" + ? { ...cur, sessions: cur.source === "scope" ? sessionsForScope(sessions, cur.filter) : sessions } + : cur, + ); + }, + [state.running, renameSession, listSessions], + ); + const onRestoreTrashedSession = useCallback( + async (path: string) => { + await restoreSession(path); + const trashed = await listTrashedSessions(); + setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed })); + }, + [restoreSession, listTrashedSessions], + ); + const onPurgeTrashedSession = useCallback( + async (path: string) => { + await purgeTrashedSession(path); + const trashed = await listTrashedSessions(); + setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed })); + }, + [purgeTrashedSession, listTrashedSessions], + ); + const onPurgeAllTrashedSessions = useCallback( + async (paths: string[]) => { + const uniquePaths = Array.from(new Set(paths)); + for (const path of uniquePaths) { + await purgeTrashedSession(path); + } + const trashed = await listTrashedSessions(); + setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed })); + }, + [purgeTrashedSession, listTrashedSessions], + ); + const onPurgeRecoveryCopies = useCallback( + async (paths: string[]) => { + const uniquePaths = Array.from(new Set(paths)); + for (const path of uniquePaths) { + // Permanent copy cleanup must not trust the list result: the backend + // rechecks the trashed transcript and its live parent for every path. + await app.PurgeRecoveryCopy(path).catch(() => undefined); + } + const trashed = await listTrashedSessions(); + setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed })); + }, + [listTrashedSessions], + ); + + // Workspace: open the folder chooser and switch projects. The hook resets the + // transcript and refreshes meta on a pick. A cancel is a no-op. + const switchFolder = useCallback(async (path?: string) => { + const picked = path === undefined ? await pickWorkspace() : await switchWorkspace(path); + if (picked) { + setProjectRevision((value) => value + 1); + await refreshTabMetas(); + } + return picked; + }, [pickWorkspace, switchWorkspace, refreshTabMetas]); + + const refreshProjectsAndTabs = useCallback(async () => { + setProjectRevision((value) => value + 1); + const tabs = await refreshTabMetas(); + if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) { + await syncActiveTab(false); + } + }, [activeTabId, refreshTabMetas, syncActiveTab]); + + const renameTopic = useCallback(async (topicId: string, title: string) => { + const nextTitle = title.trim(); + if (!topicId || !nextTitle) return; + try { + await app.RenameTopic(topicId, nextTitle); + await refreshProjectsAndTabs(); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } + }, [refreshProjectsAndTabs, showToast]); + + const startActiveTopicRename = useCallback(() => { + if (!activeTab?.topicId) return; + topicRenameSkipCommitRef.current = false; + topicRenameCommitHandledRef.current = false; + setRenamingTopicId(activeTab.topicId); + setTopicTitleDraft(activeTab.topicTitle || ""); + }, [activeTab?.topicId, activeTab?.topicTitle]); + + const cancelActiveTopicRename = useCallback(() => { + topicRenameSkipCommitRef.current = true; + topicRenameCommitHandledRef.current = true; + setRenamingTopicId(null); + setTopicTitleDraft(""); + }, []); + + const commitActiveTopicRename = useCallback(async () => { + if (topicRenameSkipCommitRef.current) { + topicRenameSkipCommitRef.current = false; + topicRenameCommitHandledRef.current = false; + setRenamingTopicId(null); + return; + } + if (topicRenameCommitHandledRef.current) return; + topicRenameCommitHandledRef.current = true; + const topicId = renamingTopicId; + setRenamingTopicId(null); + if (!topicId) return; + const nextTitle = topicTitleDraft.trim(); + if (!nextTitle) return; + try { + await renameTopic(topicId, nextTitle); + } catch { + /* keep the app usable if a stale topic cannot be renamed */ + } + }, [renameTopic, renamingTopicId, topicTitleDraft]); + + const sidebarExpandBlocked = false; + const sidebarToggleTitle = sidebarCollapsed + ? t("sidebar.expand") + : t("sidebar.collapse"); + const sidebarNavTooltipDisabled = !sidebarCollapsed; + const browserPreviewChrome = typeof window !== "undefined" && !window.runtime; + const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : ""; + const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined; + const workspacePanelResetWidth = rightDockDetailActive + ? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH + : desktopLayoutStyle === "creation" + ? defaultCreationRightDockTreeWidth() + : defaultRightDockTreeWidth(); + const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth); + const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH; + const sidebarCreation = desktopLayoutStyle === "creation"; + const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab); + const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : ""; + const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : ""; + const topicbarImSource = activeTab?.scope === "global" && activeTab.topicId ? imTopicSources[activeTab.topicId] : undefined; + const topicbarImSourceLabel = sidebarImDetailConnection + ? sidebarImDetailConnection.platformLabel + : topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : ""; + const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform; + const topicbarSubtitleVisible = !sidebarCreation && Boolean(topicbarWorkspaceLabel || topicbarImSourceLabel); + const topicbarSubtitleTitle = sidebarImDetailConnection + ? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ") + : [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · "); + const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId); + const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1)); + const sidebarWorkbench = desktopLayoutStyle === "workbench"; + const windowsFramelessChrome = desktopPlatform === "windows"; + const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent) => { + if (!windowsFramelessChrome) return; + const target = event.target as HTMLElement | null; + if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return; + if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return; + event.preventDefault(); + void app.ToggleMaximiseMainWindow(); + }, [windowsFramelessChrome]); + // Creation keeps the classic sidebar/chat structure while gating chrome tweaks + // behind its own style flag so classic/workbench remain unchanged. + const appChromeHidden = sidebarWorkbench || sidebarCreation; + const workbenchChromeHidden = sidebarWorkbench; + const sidebarClassName = [ + "sidebar", + sidebarCollapsed ? "sidebar--collapsed" : "", + sidebarWorkbench ? "sidebar--workbench" : "", + ].filter(Boolean).join(" "); + + return ( + + + +
+
+ {!appChromeHidden && ( + void handleTabChange(id)} + onTabClose={(id) => void handleTabClose(id)} + onTabsClose={(ids, nextActiveTabId) => void handleTabsClose(ids, nextActiveTabId)} + onTabsReorder={(ids) => void handleTabsReorder(ids)} + onNewTab={() => void handleNewTab()} + onOpenPalette={() => void openPalette()} + /> + )} + + {t("shortcuts.skipToComposer")} + + + + + )} + +
+ <> +
+ {workbenchChromeHidden && ( + + + + )} +
+
+ {topicbarEditing ? ( +
+ setTopicTitleDraft(event.target.value)} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitActiveTopicRename(); + } + if (event.key === "Escape") { + event.preventDefault(); + cancelActiveTopicRename(); + } + }} + onBlur={() => void commitActiveTopicRename()} + /> +
+ ) : sidebarCreation && topicbarCanRename ? ( +

+ +

+ ) : ( +

{topicbarTitle}

+ )} + {!sidebarCreation && ( + + + + )} +
+ {topicbarSubtitleVisible && ( +
+ {topicbarWorkspaceLabel && {topicbarWorkspaceLabel}} + {topicbarImSourcePlatform && ( + + {topicbarImSourceLabel} + + )} +
+ )} +
+
+
+ {workbenchChromeHidden && ( + + + + )} + {!sidebarImDetailConnection && ( + <> + + + +
+ + + + {topicExportOpen && ( +
+ + + + +
+ )} +
+ + )} + {!sidebarCreation && ( + + + + )} + + + + + + + {sidebarCreation && ( + + + + )} +
+
+ + {state.meta?.startupErr && ( +
{t("topbar.startupError", { msg: state.meta.startupErr })}
+ )} + + + +
+ {sidebarImDetailConnection ? ( + setSidebarImDetailConnectionId("")} + onOpenSettings={openBotSettings} + onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)} + onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)} + /> + ) : noticePreviewMockEnabled() ? ( + + ) : ( + activeTabId && loadOlderHistory(activeTabId)} + invocationMetadata={activeTabId ? invocationMetadataByTab[activeTabId] : undefined} + /> + )} +
+ + {!sidebarImDetailConnection && ( +
+ {showTodos && ( + + )} + {rewindState && ( + { + if (activeTabId) setRewindStateForTab(activeTabId, null); + if (activeTabId) { + setComposerInsertRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: Date.now(), text: "", mode: "replace" }, + })); + } + }, + }} + /> + )} + {decisionSurface === "tool_approval" || decisionSurface === "plan_approval" + ? state.approval && ( + setWorkspaceInsertTarget(active ? "planRevision" : "composer")} + onAnswer={async (allow, session, persist) => { + // Approving an exit_plan_mode plan leaves plan mode; await the + // mode switch before sending the approval so the controller + // observes the updated state before it unblocks. + if (state.approval!.tool === "exit_plan_mode" && allow) await applyCollaborationMode("normal"); + approve(state.approval!.id, allow, session, persist); + }} + onRevisePlan={(text) => { + if (activeTabId) { + setPendingPlanRevisionsByTab((current) => ({ ...current, [activeTabId]: text })); + } + approve(state.approval!.id, false, false, false); + }} + onExitPlan={async () => { + await applyCollaborationMode("normal"); + approve(state.approval!.id, false, false, false); + }} + onStop={() => { + cancel(); + }} + toolApprovalMode={toolApprovalMode} + /> + ) + : decisionSurface === "ask" + ? state.ask && ( + answerQuestion(state.ask!.id, [])} + onStop={() => { + cancel(); + }} + /> + ) + : decisionSurface === "clear_context" ? ( + { + void confirmClearContext(); + }} + /> + ) : null} + {/* Composer stays mounted under a decision so per-session draft + caches (text, attachments, paste blocks, guidance) survive. */} + + +
+ )} + +
+ + {workspacePanelGridOpen && ( + + )} + + +
+
+
+ {rightDockMode === "context" && desktopLayoutStyle !== "creation" ? ( + + ) : ( + setWorkspacePanel(false)} + onToggleMaximized={() => { + closeTransientOverlays(); + setWorkspacePanelMaximized((value) => !value); + }} + onPreviewModeChange={handleWorkspacePreviewModeChange} + onAddToChat={addWorkspaceTextToComposer} + onRequestPanelWidth={ensureWorkspacePanelWidth} + onFileTreeRefresh={refreshComposerFileRefs} + refreshKey={dockRefreshKey} + initialViewMode={rightDockMode === "changed" ? "changed" : "files"} + showViewTabs={false} + creationMode={sidebarCreation} + /> + )} +
+ + )} + + + {histView !== null && ( + + + + )} + + {settingsTarget !== null && ( + + { + setSettingsFocus(null); + setSettingsTarget(null); + }} + onChanged={(settings) => { + void refreshMeta(); + if (settings) { + applyDesktopPreferences(settings); + void refreshSidebarImConnectionsFromSettings(settings).catch((e) => console.warn("bot sidebar refresh failed", e)); + return; + } + void reloadSidebarImConnections().catch((e) => console.warn("bot sidebar refresh failed", e)); + void app.DesktopStartupSettings() + .then(applyDesktopPreferences) + .catch((e) => console.warn("desktop preferences refresh failed", e)); + }} + /> + + )} + + setPaletteOpen(false)} + items={paletteItems} + placeholder={t("palette.placeholder")} + emptyText={t("palette.empty")} + /> + + setShortcutsOpen(false)} + t={t} + /> + + {startupSplashVisible && ( + setStartupSplashVisible(false)} /> + )} + + {needsOnboarding && setNeedsOnboarding(false)} />} + + setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => { + void handleOpenTopic(scope, workspaceRoot, topicId); + }} /> + {windowsFramelessChrome && } + +
+ ); +} diff --git a/desktop/frontend/src/__tests__/anchored-popover-scroll.test.tsx b/desktop/frontend/src/__tests__/anchored-popover-scroll.test.tsx new file mode 100644 index 0000000..7a9d109 --- /dev/null +++ b/desktop/frontend/src/__tests__/anchored-popover-scroll.test.tsx @@ -0,0 +1,129 @@ +// Run: tsx src/__tests__/anchored-popover-scroll.test.tsx + +import { JSDOM } from "jsdom"; +import React, { useRef } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { AnchoredPopover } from "../components/AnchoredPopover"; + +type RectParts = Pick; + +let passed = 0; +let failed = 0; + +function ok(value: boolean, label: string) { + if (value) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}\n`); + failed += 1; + } +} + +function eq(actual: unknown, expected: unknown, label: string) { + if (actual === expected) { + ok(true, label); + } else { + ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function rect({ left, top, right, bottom, width, height }: RectParts): DOMRect { + return { + left, + top, + right, + bottom, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + +async function nextFrame() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + }); +} + +function Harness() { + const anchorRef = useRef(null); + return ( + <> + + {}} + className="test-popover" + placement="bottom" + > +
Menu
+
+ + ); +} + +console.log("\nanchored popover scroll positioning"); + +const dom = new JSDOM("
", { + pretendToBeVisual: true, +}); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +globalThis.window = dom.window as unknown as Window & typeof globalThis; +globalThis.document = dom.window.document; +globalThis.Node = dom.window.Node; +globalThis.HTMLElement = dom.window.HTMLElement; +globalThis.Event = dom.window.Event; +globalThis.KeyboardEvent = dom.window.KeyboardEvent; +globalThis.MouseEvent = dom.window.MouseEvent; +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); +globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); + +Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 }); +Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 }); + +let anchorTop = 100; +const originalGetBoundingClientRect = dom.window.HTMLElement.prototype.getBoundingClientRect; +dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() { + if (this instanceof dom.window.HTMLElement && this.dataset.testid === "anchor") { + return rect({ left: 60, top: anchorTop, right: 260, bottom: anchorTop + 30, width: 200, height: 30 }); + } + if (this instanceof dom.window.HTMLElement && this.dataset.anchoredPopover === "active") { + return rect({ left: 0, top: 0, right: 240, bottom: 120, width: 240, height: 120 }); + } + return originalGetBoundingClientRect.call(this); +}; + +const rootEl = document.getElementById("root"); +if (!rootEl) throw new Error("missing root"); +const root = createRoot(rootEl); + +await act(async () => { + root.render(); +}); +await nextFrame(); + +const popover = document.querySelector("[data-anchored-popover='active']"); +if (!popover) throw new Error("popover did not render"); + +eq(popover.style.top, "138px", "popover starts below the anchor"); + +await act(async () => { + anchorTop = 40; + window.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); +}); + +eq(popover.style.top, "78px", "popover follows the anchor after a scroll event"); + +await act(async () => { + root.unmount(); +}); +dom.window.close(); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts b/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts new file mode 100644 index 0000000..c15e781 --- /dev/null +++ b/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts @@ -0,0 +1,446 @@ +// Run: tsx src/__tests__/app-chrome-tabs.test.ts + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const appChromeSource = readFileSync(resolve(testDir, "../components/AppChrome.tsx"), "utf8"); +const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8"); +const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8"); +const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8"); +const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8"); +const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8"); +const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8"); +const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8"); +const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8"); +const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +let passed = 0; +let failed = 0; + +function ok(value: unknown, label: string) { + if (value) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}\n`); + failed += 1; + } +} + +function matchingBlocks(selector: string): string[] { + const blocks: string[] = []; + const rule = /([^{}]+)\{([^{}]*)\}/g; + let match: RegExpExecArray | null; + while ((match = rule.exec(stylesSource)) !== null) { + const selectors = match[1].split(",").map((part) => part.trim()); + if (selectors.includes(selector)) blocks.push(match[2]); + } + return blocks; +} + +function finalDeclaration(selector: string, property: string): string | undefined { + let value: string | undefined; + for (const block of matchingBlocks(selector)) { + const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g"); + let match: RegExpExecArray | null; + while ((match = declaration.exec(block)) !== null) { + value = match[1].trim(); + } + } + return value; +} + +console.log("\napp chrome tabs"); + +ok( + /import \{ TabBar \} from "\.\/TabBar";/.test(appChromeSource), + "AppChrome keeps the classic top session tab strip implementation", +); + +for (const propName of ["onTabChange", "onTabClose", "onTabsClose", "onTabsReorder", "onNewTab"]) { + ok( + new RegExp(`\\b${propName}\\b`).test(appChromeSource), + `AppChrome exposes ${propName} for classic tabs`, + ); +} + +ok( + /app-chrome__tab-strip/.test(appChromeSource), + "AppChrome markup includes classic tab strip containers", +); + +ok( + /const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) && + /\{titlebarDragRail && .tooltip-trigger:has(.tabbar__new)", "flex")?.includes("--chrome-panel-control-size"), + "themed AppChrome new-tab button keeps a stable slot beside the tabs", +); + +ok( + /workbenchChrome \? \(\s*