diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..cd9cf45c1 --- /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/workflows/ci.yml b/.github/workflows/ci.yml index f43329e4e..92527cf9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -58,9 +58,9 @@ jobs: race: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -79,20 +79,20 @@ jobs: run: working-directory: desktop steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10 run_install: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "24" cache: pnpm @@ -145,15 +145,15 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: version: v2.12.2 args: --timeout=5m @@ -162,9 +162,9 @@ jobs: runs-on: ubuntu-latest continue-on-error: true # informational — stdlib vulns need a Go patch release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -178,9 +178,9 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -189,7 +189,7 @@ jobs: run: go test -coverprofile=coverage.out -covermode=atomic ./... - name: upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage.out diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..413818f9a --- /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@v6 + - 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/e2e-bot.yml b/.github/workflows/e2e-bot.yml index 6d7685f42..755e44b8f 100644 --- a/.github/workflows/e2e-bot.yml +++ b/.github/workflows/e2e-bot.yml @@ -25,9 +25,14 @@ jobs: 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@v7 + uses: actions/github-script@v9 with: script: | await github.rest.reactions.createForIssueComment({ @@ -37,16 +42,16 @@ jobs: # Default-branch checkout: this is where the harness (cmd/e2ebench), the # suite, and a run --metrics-capable agent live. - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version-file: go.mod cache: true - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.12' @@ -62,7 +67,13 @@ jobs: - name: Check out the PR head env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr checkout ${{ github.event.issue.number }} + # 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 diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml index ef232ea55..7b1a9c0b1 100644 --- a/.github/workflows/issue-auto-label.yml +++ b/.github/workflows/issue-auto-label.yml @@ -20,7 +20,7 @@ jobs: classify: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} with: diff --git a/.github/workflows/issue-version-label.yml b/.github/workflows/issue-version-label.yml index 7e3256abf..81c9578fb 100644 --- a/.github/workflows/issue-version-label.yml +++ b/.github/workflows/issue-version-label.yml @@ -18,7 +18,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const body = context.payload.issue.body || ''; diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d805453e4..a2d9fc755 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -19,8 +19,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: '22' cache: npm @@ -30,7 +30,7 @@ jobs: run: | npm ci npm run build - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: site/dist @@ -42,4 +42,4 @@ jobs: url: ${{ steps.deploy.outputs.page_url }} steps: - id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index 6d8455182..6f1b429b7 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -23,6 +23,6 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: sync-labels: false diff --git a/.github/workflows/pr-version-label.yml b/.github/workflows/pr-version-label.yml index e34908ab0..d2d764e4d 100644 --- a/.github/workflows/pr-version-label.yml +++ b/.github/workflows/pr-version-label.yml @@ -20,7 +20,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const base = context.payload.pull_request.base.ref; diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 26c0ca54a..91d8c8137 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -42,9 +42,9 @@ jobs: name: cache hit guard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -69,18 +69,18 @@ jobs: run: shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10 @@ -92,6 +92,12 @@ jobs: 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' @@ -163,7 +169,7 @@ jobs: MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} run: go run ./cmd/sign sign ../dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.name }} path: dist/* @@ -177,15 +183,15 @@ jobs: # 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@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: dist pattern: dist-* @@ -222,7 +228,7 @@ jobs: - name: Upload canary dist for mirror if: steps.ver.outputs.channel == 'canary' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: canary-dist path: dist/* @@ -237,7 +243,7 @@ jobs: env: HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: ver @@ -254,7 +260,7 @@ jobs: # 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@v4 + uses: actions/download-artifact@v8 with: name: canary-dist path: assets diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 3f01aaf2b..d50bab73a 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -33,8 +33,8 @@ jobs: name: cache hit guard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -48,12 +48,12 @@ jobs: # `release` environment (esengine approves); a canary dispatch runs free. environment: ${{ github.event_name == 'workflow_dispatch' && 'canary' || 'release' }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: '22' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efb7ea10a..0d47986c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,8 @@ jobs: name: cache hit guard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -34,14 +34,14 @@ jobs: # `release` environment so only esengine can approve it going public. environment: release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - - uses: goreleaser/goreleaser-action@v6 + - uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' args: release --clean @@ -57,7 +57,7 @@ jobs: env: HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download CodeGraph release assets if: env.HAS_R2 == 'true' diff --git a/.golangci.yml b/.golangci.yml index 979d92e15..fcfdb9a6d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -33,6 +33,15 @@ linters: - 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 diff --git a/README.md b/README.md index cbc88c58e..355351787 100644 --- a/README.md +++ b/README.md @@ -220,10 +220,11 @@ convenient. ### Slash commands -In `reasonix chat`, built-in commands (`/compact`, `/new`/`/clear`, `/rewind`, `/tree`, +In `reasonix chat`, built-in commands (`/compact`, `/new`, `/clear`, `/rewind`, `/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/effort`, `/mcp`, `/memory`, `/help`) run locally. -`/new` starts a fresh model context while saving the previous transcript for -history/resume; `/clear` is the Claude Code-compatible alias. +`/new` starts a new session while saving the previous transcript for +history/resume; `/clear` asks for confirmation, then discards the current context +without saving it. `/tree` shows saved conversation branches, `/branch [name]` forks the current conversation tip, `/branch [name]` forks from an earlier checkpointed turn, and `/switch ` loads another branch. **Custom commands** are Markdown files under diff --git a/README.zh-CN.md b/README.zh-CN.md index a7ad6e74d..3ac1dc007 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -200,8 +200,8 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" } ### 斜杠命令 -`reasonix chat` 里,内置命令(`/compact`、`/new`(`/clear`)、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。 -`/new` 会开启干净的模型上下文,同时保存之前的 transcript 供历史记录和恢复使用;`/clear` 是兼容 Claude Code 习惯的同义命令。 +`reasonix chat` 里,内置命令(`/compact`、`/new`、`/clear`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。 +`/new` 会开启新会话,同时保存之前的 transcript 供历史记录和恢复使用;`/clear` 会二次确认,确认后丢弃当前上下文且不保存。 `/tree` 查看已保存的对话分支,`/branch [name]` 从当前对话末端分支,`/branch [name]` 从较早的 checkpoint 轮次分支,`/switch ` 切换到另一个分支。**自定义命令** 是放在 `.reasonix/commands/`(项目)或 `~/.config/reasonix/commands/`(用户)下的 Markdown 文件—— diff --git a/benchmarks/e2e/tasks/subagent-delegation/task.toml b/benchmarks/e2e/tasks/subagent-delegation/task.toml new file mode 100644 index 000000000..509fa48c3 --- /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 000000000..9bedcc368 --- /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 000000000..0d9353596 --- /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 000000000..53aae3d01 --- /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 000000000..c9939115d --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt @@ -0,0 +1 @@ +gamma=41 diff --git a/desktop/.gitignore b/desktop/.gitignore index 3e30c7aac..d4f6c4e5f 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -19,6 +19,14 @@ /build/darwin/* !/build/darwin/entitlements.plist +# Commit the hand-written Linux .deb packaging inputs (nfpm config + .desktop entry). +# `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 + # Go test binaries *.test diff --git a/desktop/app.go b/desktop/app.go index 74d8ee4f4..b0ebb2c02 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -75,6 +75,7 @@ type App struct { tray *desktopTray mediaTokens *mediaTokenStore + botInstalls map[string]*botInstallSession } // mediaTokenEntry holds metadata for a workspace media file served via temporary URL. @@ -243,7 +244,7 @@ func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler { // NewApp constructs the bound object. Tabs are restored in startup from the // last session's desktop-tabs.json. func NewApp() *App { - return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore()} + return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), botInstalls: map[string]*botInstallSession{}} } func (a *App) bootContext() context.Context { @@ -376,6 +377,11 @@ func (a *App) restoreOrBuildTabs() { tab.model = entry.Model tab.effort = cloneStringPtr(entry.Effort) tab.mode = persistedTabMode(entry.Mode) + tab.goal = 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.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} a.mu.Lock() @@ -418,13 +424,14 @@ func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab { return &WorkspaceTab{ - ID: id, - Scope: scope, - WorkspaceRoot: workspaceRoot, - TopicID: topicID, - TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), - mode: "normal", - disabledMCP: map[string]ServerView{}, + ID: id, + Scope: scope, + WorkspaceRoot: workspaceRoot, + TopicID: topicID, + TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), + mode: "normal", + toolApprovalMode: control.ToolApprovalAsk, + disabledMCP: map[string]ServerView{}, } } @@ -601,18 +608,21 @@ func (a *App) ApproveTabWithScope(tabID, id string, allow, session, persist bool } } -// SetPlanMode toggles read-only plan mode. +// SetPlanMode toggles the read-only plan axis while preserving the current +// tool-auto-approval axis. func (a *App) SetPlanMode(on bool) { - if on { - a.SetModeForTab("", "plan") - return - } - a.SetModeForTab("", "normal") + 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" | anything else = +// 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 SetPlanMode/SetBypass pair. +// half-applied plan/tool-auto-approval pair. func (a *App) SetMode(mode string) { a.SetModeForTab("", mode) } @@ -626,10 +636,18 @@ func (a *App) SetModeForTab(tabID, mode string) { 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() @@ -646,11 +664,81 @@ func applyTabModeToController(ctrl *control.Controller, mode string) { 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.Controller, 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"` @@ -704,6 +792,22 @@ func (a *App) NewSession() error { return nil } +// ClearSession discards the current conversation and rotates to a fresh unsaved one. +func (a *App) ClearSession() error { + a.mu.RLock() + tab := a.activeTabLocked() + ctrl := a.activeCtrlLocked() + a.mu.RUnlock() + if ctrl == nil { + return nil + } + if err := ctrl.ClearSession(); err != nil { + return err + } + a.persistTabSessionPath(tab, ctrl.SessionPath()) + return nil +} + // CheckpointMeta summarises one rewind point (a user turn) for the desktop. type CheckpointMeta struct { Turn int `json:"turn"` @@ -741,6 +845,16 @@ func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta { 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. + hasCodeAfter := false + for i := len(out) - 1; i >= 0; i-- { + if len(out[i].Files) > 0 { + hasCodeAfter = true + } + out[i].CanCode = hasCodeAfter + } return out } @@ -881,11 +995,42 @@ type WorkspaceMeta struct { Current bool `json:"current"` } +func controllerSessionDir(ctrl *control.Controller) string { + if ctrl != nil { + if dir := ctrl.SessionDir(); dir != "" { + return dir + } + } + return desktopSessionDir("") +} + +func tabSessionDir(tab *WorkspaceTab) string { + if tab != nil { + if tab.Ctrl != nil { + if dir := tab.Ctrl.SessionDir(); dir != "" { + return dir + } + } + if tab.WorkspaceRoot != "" { + return desktopSessionDir(tab.WorkspaceRoot) + } + } + return desktopSessionDir("") +} + +func (a *App) activeSessionDir() string { + a.mu.RLock() + tab := a.activeTabLocked() + dir := tabSessionDir(tab) + a.mu.RUnlock() + return dir +} + // 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 := config.SessionDir() + dir := a.activeSessionDir() infos, err := agent.ListSessions(dir) if err != nil { return []SessionMeta{} @@ -904,20 +1049,21 @@ func (a *App) ListSessions() []SessionMeta { // 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 { - dir := config.SessionDir() - paths, err := listTrashedSessionFiles(dir) - if err != nil { - return []SessionMeta{} - } - titles := loadSessionTitles(dir) - out := make([]SessionMeta, 0, len(paths)) - for _, path := range paths { - infos, err := agent.ListSessions(filepath.Dir(path)) - if err != nil || len(infos) == 0 { + out := []SessionMeta{} + for _, dir := range a.knownSessionDirs() { + paths, err := listTrashedSessionFiles(dir) + if err != nil { continue } - deletedAt := trashedSessionDeletedAt(path) - out = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt)) + titles := loadSessionTitles(dir) + for _, path := range paths { + infos, err := agent.ListSessions(filepath.Dir(path)) + if err != nil || len(infos) == 0 { + continue + } + deletedAt := trashedSessionDeletedAt(path) + out = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt)) + } } sort.Slice(out, func(i, j int) bool { if out[i].DeletedAt == out[j].DeletedAt { @@ -928,6 +1074,25 @@ func (a *App) ListTrashedSessions() []SessionMeta { 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) SessionMeta { return SessionMeta{ Path: s.Path, @@ -950,7 +1115,7 @@ func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, // DeleteSession moves a saved session to the local trash. It refuses any open // session because tab auto-save would recreate or append to the file later. func (a *App) DeleteSession(path string) error { - dir := config.SessionDir() + dir := a.activeSessionDir() sessionPath, key, err := validateSessionPath(dir, path) if err != nil { return err @@ -1001,7 +1166,10 @@ func (a *App) activeSessionPath(dir string) string { // RestoreSession moves a trashed session back into the saved-session list. func (a *App) RestoreSession(path string) error { - dir := config.SessionDir() + dir, err := a.trashedSessionDir(path) + if err != nil { + return err + } _, key, _, err := validateTrashedSessionPath(dir, path) if err != nil { return err @@ -1019,13 +1187,17 @@ func (a *App) RestoreSession(path string) error { // PurgeTrashedSession permanently removes a trashed session and its title/display // sidecars. func (a *App) PurgeTrashedSession(path string) error { - return purgeTrashedSessionFile(config.SessionDir(), path) + dir, err := a.trashedSessionDir(path) + if err != nil { + return err + } + return purgeTrashedSessionFile(dir, path) } // RenameSession sets a custom display name for a session (empty clears it back to // the preview). It only affects the history panel; the file on disk is unchanged. func (a *App) RenameSession(path, title string) error { - return setSessionTitle(config.SessionDir(), path, title) + return setSessionTitle(a.activeSessionDir(), path, title) } // ResumeSession snapshots the current conversation, then loads the session at @@ -1046,20 +1218,28 @@ func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) return []HistoryMessage{}, fmt.Errorf("tab is not ready") } ctrl := tab.Ctrl - loaded, err := agent.LoadSession(path) + sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) + if err != nil { + return nil, err + } + loaded, err := agent.LoadSession(sessionPath) if err != nil { return nil, err } _ = ctrl.Snapshot() // persist the current session before switching away - ctrl.Resume(loaded, path) - a.rememberTabSessionPath(tab, path) + ctrl.Resume(loaded, sessionPath) + a.rememberTabSessionPath(tab, sessionPath) return a.HistoryForTab(tabID), nil } // 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) { - return previewSessionMessages(config.SessionDir(), path) + sessionDir, sessionPath, err := a.sessionDirForPath(path) + if err != nil { + return nil, err + } + return previewSessionMessages(sessionDir, sessionPath) } // PickWorkspace opens a folder chooser and, on a pick, opens a new project tab @@ -1274,7 +1454,7 @@ func (a *App) HistoryForTab(tabID string) []HistoryMessage { return []HistoryMessage{} } msgs := ctrl.History() - return historyMessages(msgs, sessionDisplayResolver(config.SessionDir(), ctrl.SessionPath())) + return historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath())) } func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage { @@ -1283,6 +1463,9 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st content := m.Content if m.Role == provider.RoleUser { content = resolveUserContent(m.Content) + if control.IsSyntheticUserMessage(content) { + continue + } } reasoning := "" if m.Role == provider.RoleAssistant { @@ -1305,14 +1488,18 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st } func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) { - if out, ok, err := previewEventSessionMessages(path); ok || err != nil { + 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(path) + loaded, err := agent.LoadSession(sessionPath) if err != nil { return nil, err } - return historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, path)), nil + return historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil } type previewEventRecord struct { @@ -1540,12 +1727,16 @@ func (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView { // 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"` - Bypass bool `json:"bypass"` // YOLO mode on (auto-approve every tool call) + Label string `json:"label"` + Ready bool `json:"ready"` + StartupErr string `json:"startupErr,omitempty"` + EventChannel string `json:"eventChannel"` + Cwd string `json:"cwd"` + AutoApproveTools bool `json:"autoApproveTools"` + Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval + ToolApprovalMode string `json:"toolApprovalMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` } // Meta reports the model label, readiness, any startup error, the working @@ -1564,25 +1755,110 @@ func (a *App) MetaForTab(tabID string) Meta { if cwd == "" { cwd, _ = os.Getwd() } + autoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools() + toolApprovalMode := currentTabToolApprovalMode(tab) + goal := currentTabGoal(tab) + goalStatus := currentTabGoalStatus(tab) return Meta{ - Label: tab.Label, - Ready: tab.Ready, - StartupErr: tab.StartupErr, - EventChannel: eventChannel, - Cwd: cwd, - Bypass: tab.Ctrl != nil && tab.Ctrl.Bypass(), + Label: tab.Label, + Ready: tab.Ready, + StartupErr: tab.StartupErr, + EventChannel: eventChannel, + Cwd: cwd, + AutoApproveTools: autoApproveTools, + Bypass: autoApproveTools, + ToolApprovalMode: toolApprovalMode, + Goal: goal, + GoalStatus: goalStatus, } } -// SetBypass toggles YOLO mode for the session: auto-approve every tool call -// (writers and bash run without asking). Deny rules still apply. Runtime-only — -// not written to config, so it resets on relaunch. -func (a *App) SetBypass(on bool) { +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) +} + +func (a *App) setAutoApproveToolsForTab(tabID string, on bool) { if on { - a.SetModeForTab("", "yolo") + a.SetToolApprovalModeForTab(tabID, control.ToolApprovalYolo) + return + } + a.SetToolApprovalModeForTab(tabID, 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 } - a.SetModeForTab("", "normal") + 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. @@ -1599,10 +1875,12 @@ type CommandInfo struct { func (a *App) Commands() []CommandInfo { out := []CommandInfo{ {Name: "new", Description: i18n.M.CmdNew, Kind: "builtin"}, + {Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin"}, {Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin"}, {Name: "model", Description: i18n.M.CmdModel, Kind: "builtin"}, {Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin"}, {Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin"}, + {Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin"}, {Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin"}, {Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin"}, {Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin"}, @@ -2846,6 +3124,7 @@ func (a *App) SetModelForTab(tabID, name string) error { RequireKey: false, Sink: tab.sink, WorkspaceRoot: tab.WorkspaceRoot, + SessionDir: tabSessionDir(tab), EffortOverride: cloneStringPtr(effortOverride), }) if err != nil { @@ -2861,6 +3140,8 @@ func (a *App) SetModelForTab(tabID, name string) error { a.mu.Unlock() newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, tab.mode) + applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode) + newCtrl.SetGoal(tab.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if len(carried) > 0 { @@ -2937,6 +3218,7 @@ func (a *App) SetEffortForTab(tabID, level string) error { RequireKey: false, Sink: tab.sink, WorkspaceRoot: tab.WorkspaceRoot, + SessionDir: tabSessionDir(tab), EffortOverride: &effort, }) if err != nil { @@ -2953,6 +3235,8 @@ func (a *App) SetEffortForTab(tabID, level string) error { a.mu.Unlock() newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, tab.mode) + applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode) + newCtrl.SetGoal(tab.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if len(carried) > 0 { newCtrl.Resume(&agent.Session{Messages: carried}, path) @@ -3233,10 +3517,10 @@ func (a *App) SearchFileRefs(query string) []DirEntry { if err != nil { return nil } - paths := fileref.Search(base, query, fileRefSearchLimit) - out := make([]DirEntry, 0, len(paths)) - for _, path := range paths { - out = append(out, DirEntry{Name: path, IsDir: false}) + 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}) } return out } diff --git a/desktop/app_test.go b/desktop/app_test.go index 4e8109fee..c349a8b84 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -1155,6 +1156,98 @@ func TestDeleteSessionRejectsInactiveOpenTab(t *testing.T) { } } +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) + } + 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 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 diff --git a/desktop/bot_connection_app.go b/desktop/bot_connection_app.go new file mode 100644 index 000000000..9556c85d8 --- /dev/null +++ b/desktop/bot_connection_app.go @@ -0,0 +1,619 @@ +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/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"` + 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"` + 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"` +} + +type botInstallSession struct { + Provider string + Domain 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" { + session, err := weixin.StartLogin(context.Background()) + 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) + a.mu.RLock() + session := a.botInstalls[installID] + a.mu.RUnlock() + if session == nil { + return BotInstallPollResult{Error: "install session not found"}, nil + } + if time.Now().After(session.ExpireAt) { + a.deleteBotInstall(installID) + return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil + } + if session.Provider == "weixin" { + result, status, err := weixin.PollLogin(context.Background(), 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", + 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" + } + }) + if err != nil { + return BotInstallPollResult{Status: "error", Error: err.Error()}, nil + } + 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 := config.Load() + if err != nil { + return BotConnectionDiagnostic{ID: id, Status: "error", Message: err.Error()}, nil + } + for _, conn := range cfg.Bot.Connections { + if conn.ID == id { + status := "ok" + message := "连接配置已保存。" + if !conn.Enabled { + status = "disabled" + message = "连接已保存但未启用。" + } else if conn.Status != "connected" { + status = firstNonEmptyBot(conn.Status, "pending") + message = firstNonEmptyBot(conn.LastError, "连接还未完成。") + } else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) { + status = "warning" + message = conn.Credential.AppSecretEnv + " 未设置。" + } + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: status, Message: message}, nil + } + } + return BotConnectionDiagnostic{ID: id, Status: "missing", Message: "未找到连接。"}, nil +} + +func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) { + cfg, err := config.Load() + if err != nil { + return BotConnectionDiagnostic{ID: id, Status: "error", Message: err.Error()}, 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{ID: id, Status: "missing", Message: "未找到连接。"}, nil + } + target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings)) + if conn.Provider != "feishu" && conn.Provider != "weixin" { + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "warning", Message: "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。"}, nil + } + if target == "" { + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "warning", Message: "请输入测试会话 ID 后再发送测试消息。"}, 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{ID: conn.ID, Label: conn.Label, Status: "error", Message: err.Error()}, nil + } + _ = a.rememberBotConnectionRemote(conn.ID, target) + msg := "测试消息已发送。" + if result.MessageID != "" { + msg += " Message ID: " + result.MessageID + } + return BotConnectionDiagnostic{ID: conn.ID, Label: conn.Label, Status: "ok", Message: msg, MessageID: result.MessageID}, nil +} + +func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) { + base := feishuAccountsBase(domain) + if _, err := postFeishuInstallForm(base, map[string]string{"action": "init"}); err != nil { + return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil + } + data, err := postFeishuInstallForm(base, 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 + } + 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, 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: verifyURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil +} + +func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) { + data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(session.Domain), 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 + } + appID := stringValue(data["client_id"]) + appSecret := stringValue(data["client_secret"]) + if appID == "" || appSecret == "" { + return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil + } + a.deleteBotInstall(installID) + secretEnv := "FEISHU_BOT_APP_SECRET" + if session.Domain == "lark" { + secretEnv = "LARK_BOT_APP_SECRET" + } + if err := upsertDotEnv(secretEnv, appSecret); err != nil { + return BotInstallPollResult{Status: "error", Error: err.Error()}, nil + } + label := "飞书" + if session.Domain == "lark" { + label = "Lark" + } + conn, err := a.upsertBotConnection(config.BotConnectionConfig{ + ID: connectionID("feishu", session.Domain), + Provider: "feishu", + Domain: session.Domain, + Label: label, + Enabled: true, + Status: "connected", + Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv}, + }, func(c *config.Config) { + c.Bot.Enabled = true + c.Bot.Feishu.Enabled = true + c.Bot.Feishu.Domain = session.Domain + c.Bot.Feishu.AppID = appID + c.Bot.Feishu.AppSecretEnv = secretEnv + c.Bot.Feishu.Mode = "websocket" + c.Bot.Feishu.RequireMention = true + }) + if err != nil { + return BotInstallPollResult{Status: "error", Error: err.Error()}, nil + } + 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 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) + 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 { + c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now + c.Bot.Connections[i].UpdatedAt = now + return nil + } + } + c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{ + RemoteID: remoteID, + SessionID: "", + 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 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) { + reqBody := url.Values{} + for k, v := range body { + reqBody.Set(k, v) + } + req, err := http.NewRequest("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, + Credential: BotConnectionCredentialView{ + AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv, + SecretSet: envIsSet(firstNonEmptyBot(conn.Credential.AppSecretEnv, conn.Credential.TokenEnv)), + }, + SessionMappings: botSessionMappingViews(conn.SessionMappings), + LastError: conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt, + } +} + +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), + 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), + LastError: strings.TrimSpace(view.LastError), + CreatedAt: strings.TrimSpace(view.CreatedAt), + UpdatedAt: strings.TrimSpace(view.UpdatedAt), + } +} + +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 botSessionMappingViews(mappings []config.BotConnectionSessionMapping) []BotConnectionSessionMappingView { + if mappings == nil { + return []BotConnectionSessionMappingView{} + } + out := make([]BotConnectionSessionMappingView, 0, len(mappings)) + for _, m := range mappings { + out = append(out, BotConnectionSessionMappingView{RemoteID: m.RemoteID, SessionID: m.SessionID, UpdatedAt: m.UpdatedAt}) + } + return out +} + +func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView) []config.BotConnectionSessionMapping { + if mappings == nil { + return nil + } + out := make([]config.BotConnectionSessionMapping, 0, len(mappings)) + for _, m := range mappings { + out = append(out, config.BotConnectionSessionMapping{ + RemoteID: strings.TrimSpace(m.RemoteID), + SessionID: strings.TrimSpace(m.SessionID), + UpdatedAt: strings.TrimSpace(m.UpdatedAt), + }) + } + return out +} + +func connectionID(provider, domain string) string { + return strings.Trim(strings.ToLower(provider+"-"+domain), "-") +} + +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 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/bound_array_contract_test.go b/desktop/bound_array_contract_test.go index 269ddc290..ab8d8c2af 100644 --- a/desktop/bound_array_contract_test.go +++ b/desktop/bound_array_contract_test.go @@ -41,7 +41,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) { } if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil || got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil || - got.Sandbox.AllowWrite == nil { + got.Sandbox.AllowWrite == 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) } } diff --git a/desktop/build/linux/nfpm.yaml b/desktop/build/linux/nfpm.yaml new file mode 100644 index 000000000..85408acfd --- /dev/null +++ b/desktop/build/linux/nfpm.yaml @@ -0,0 +1,41 @@ +# 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 diff --git a/desktop/build/linux/reasonix.desktop b/desktop/build/linux/reasonix.desktop new file mode 100644 index 000000000..806276a08 --- /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/checkpoints_cancode_test.go b/desktop/checkpoints_cancode_test.go new file mode 100644 index 000000000..f9e035622 --- /dev/null +++ b/desktop/checkpoints_cancode_test.go @@ -0,0 +1,71 @@ +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) + } +} + +// 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") + } +} diff --git a/desktop/cmd/sign/main.go b/desktop/cmd/sign/main.go index 8aa205586..751072536 100644 --- a/desktop/cmd/sign/main.go +++ b/desktop/cmd/sign/main.go @@ -207,6 +207,11 @@ func genManifest(dir, version, tag string) error { // 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 "" + } if strings.Contains(name, "windows-amd64") && !strings.HasSuffix(name, "-installer.exe") { return "" } diff --git a/desktop/cmd/sign/main_test.go b/desktop/cmd/sign/main_test.go index a5403259b..f5d903144 100644 --- a/desktop/cmd/sign/main_test.go +++ b/desktop/cmd/sign/main_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "aead.dev/minisign" @@ -57,6 +58,7 @@ func TestGenManifest(t *testing.T) { "Reasonix-windows-amd64-installer.exe", "Reasonix-windows-amd64.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 } @@ -98,4 +100,13 @@ func TestGenManifest(t *testing.T) { if win.SHA256 == "" || win.Size == 0 { t.Fatalf("windows asset missing digest/size: %+v", win) } + // 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/frontend/package-lock.json b/desktop/frontend/package-lock.json index d478c6c51..f6771833c 100644 --- a/desktop/frontend/package-lock.json +++ b/desktop/frontend/package-lock.json @@ -11,22 +11,22 @@ "@tanstack/react-virtual": "^3.14.2", "highlight.js": "^11.10.0", "katex": "^0.17.0", - "lucide-react": "^0.460.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^9.0.1", + "lucide-react": "^1.17.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" }, "devDependencies": { "@types/node": "^25.9.1", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", "terser": "^5.48.0", "tsx": "^4.22.4", - "typescript": "^5.6.3", + "typescript": "^6.0.3", "vite": "^6.0.7" } }, @@ -816,9 +816,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -1308,30 +1308,23 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.29", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", - "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/unist": { @@ -1347,24 +1340,24 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn": { @@ -1994,6 +1987,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -2048,18 +2042,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2071,12 +2053,12 @@ } }, "node_modules/lucide-react": { - "version": "0.460.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", - "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/markdown-table": { @@ -2148,23 +2130,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdast-util-gfm-footnote": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", @@ -2230,6 +2195,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm/node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz", + "integrity": "sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-math": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", @@ -3108,34 +3090,30 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.7" } }, "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -3160,9 +3138,9 @@ } }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -3332,13 +3310,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -3990,9 +3965,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 0edb49adb..7257d8886 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -8,32 +8,33 @@ "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", + "check:browser-preview": "node scripts/check-browser-preview-stability.mjs", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", - "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts", + "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/goal-draft-mode.test.ts", "test:typecheck": "tsc --noEmit -p tsconfig.test.json", - "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts" + "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/goal-draft-mode.test.ts" }, "dependencies": { "@tanstack/react-virtual": "^3.14.2", "highlight.js": "^11.10.0", "katex": "^0.17.0", - "lucide-react": "^0.460.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^9.0.1", + "lucide-react": "^1.17.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" }, "devDependencies": { "@types/node": "^25.9.1", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", "terser": "^5.48.0", "tsx": "^4.22.4", - "typescript": "^5.6.3", + "typescript": "^6.0.3", "vite": "^6.0.7" }, "overrides": { diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml index 66662beca..2eebe55f2 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/desktop/frontend/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: dependencies: '@tanstack/react-virtual': specifier: ^3.14.2 - version: 3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) highlight.js: specifier: ^11.10.0 version: 11.11.1 @@ -21,17 +21,17 @@ importers: specifier: ^0.17.0 version: 0.17.0 lucide-react: - specifier: ^0.460.0 - version: 0.460.0(react@18.3.1) + specifier: ^1.17.0 + version: 1.17.0(react@19.2.7) react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: ^19.2.7 + version: 19.2.7 react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) react-markdown: - specifier: ^9.0.1 - version: 9.1.0(@types/react@18.3.29)(react@18.3.1) + 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 @@ -46,14 +46,14 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@types/react': - specifier: ^18.3.12 - version: 18.3.29 + specifier: ^19.2.17 + version: 19.2.17 '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.29) + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4)) + specifier: ^5.2.0 + version: 5.2.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4)) terser: specifier: ^5.48.0 version: 5.48.0 @@ -61,8 +61,8 @@ importers: specifier: ^4.22.4 version: 4.22.4 typescript: - specifier: ^5.6.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 vite: specifier: ^6.0.7 version: 6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4) @@ -483,8 +483,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} @@ -641,6 +641,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -656,16 +659,13 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: - '@types/react': ^18.0.0 + '@types/react': ^19.2.0 - '@types/react@18.3.29': - resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -676,11 +676,11 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} @@ -690,8 +690,8 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - baseline-browser-mapping@2.10.32: - resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + baseline-browser-mapping@2.10.34: + resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} engines: {node: '>=6.0.0'} hasBin: true @@ -703,8 +703,8 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001797: + resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -756,8 +756,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - electron-to-chromium@1.5.364: - resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + electron-to-chromium@1.5.370: + resolution: {integrity: sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==} entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} @@ -885,17 +885,13 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.460.0: - resolution: {integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + 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==} @@ -1043,8 +1039,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} parse-entities@4.0.2: @@ -1064,26 +1060,26 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: - react: ^18.3.1 + react: ^19.2.7 - react-markdown@9.1.0: - resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: '@types/react': '>=18' react: '>=18' - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} rehype-katex@7.0.1: @@ -1109,8 +1105,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -1159,8 +1155,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -1549,7 +1545,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -1626,11 +1622,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true - '@tanstack/react-virtual@3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/virtual-core': 3.17.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@tanstack/virtual-core@3.17.0': {} @@ -1661,10 +1657,12 @@ snapshots: '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -1681,15 +1679,12 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/prop-types@15.7.15': {} - - '@types/react-dom@18.3.7(@types/react@18.3.29)': + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: - '@types/react': 18.3.29 + '@types/react': 19.2.17 - '@types/react@18.3.29': + '@types/react@19.2.17': dependencies: - '@types/prop-types': 15.7.15 csstype: 3.2.3 '@types/unist@2.0.11': {} @@ -1698,14 +1693,14 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4))': + '@vitejs/plugin-react@5.2.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.27 + '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 + react-refresh: 0.18.0 vite: 6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4) transitivePeerDependencies: - supports-color @@ -1714,19 +1709,19 @@ snapshots: bail@2.0.2: {} - baseline-browser-mapping@2.10.32: {} + baseline-browser-mapping@2.10.34: {} browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.32 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.364 - node-releases: 2.0.46 + baseline-browser-mapping: 2.10.34 + caniuse-lite: 1.0.30001797 + electron-to-chromium: 1.5.370 + node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-from@1.1.2: {} - caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001797: {} ccount@2.0.1: {} @@ -1762,7 +1757,7 @@ snapshots: dependencies: dequal: 2.0.3 - electron-to-chromium@1.5.364: {} + electron-to-chromium@1.5.370: {} entities@6.0.1: {} @@ -1869,7 +1864,7 @@ snapshots: '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 - property-information: 7.1.0 + property-information: 7.2.0 vfile: 6.0.3 vfile-location: 5.0.3 web-namespaces: 2.0.1 @@ -1884,7 +1879,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -1894,7 +1889,7 @@ snapshots: mdast-util-mdx-expression: 2.0.1 mdast-util-mdx-jsx: 3.2.0 mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 unist-util-position: 5.0.0 @@ -1918,7 +1913,7 @@ snapshots: '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 highlight.js@11.11.1: {} @@ -1956,17 +1951,13 @@ snapshots: longest-streak@3.1.0: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@0.460.0(react@18.3.1): + lucide-react@1.17.0(react@19.2.7): dependencies: - react: 18.3.1 + react: 19.2.7 markdown-table@3.0.4: {} @@ -2340,7 +2331,7 @@ snapshots: nanoid@3.3.12: {} - node-releases@2.0.46: {} + node-releases@2.0.47: {} parse-entities@4.0.2: dependencies: @@ -2366,24 +2357,23 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - property-information@7.1.0: {} + property-information@7.2.0: {} - react-dom@18.3.1(react@18.3.1): + react-dom@19.2.7(react@19.2.7): dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + react: 19.2.7 + scheduler: 0.27.0 - react-markdown@9.1.0(@types/react@18.3.29)(react@18.3.1): + 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': 18.3.29 + '@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: 18.3.1 + react: 19.2.7 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -2392,11 +2382,9 @@ snapshots: transitivePeerDependencies: - supports-color - react-refresh@0.17.0: {} + react-refresh@0.18.0: {} - react@18.3.1: - dependencies: - loose-envify: 1.4.0 + react@19.2.7: {} rehype-katex@7.0.1: dependencies: @@ -2482,9 +2470,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2534,7 +2520,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@5.9.3: {} + typescript@6.0.3: {} undici-types@7.24.6: {} diff --git a/desktop/frontend/scripts/check-browser-preview-stability.mjs b/desktop/frontend/scripts/check-browser-preview-stability.mjs new file mode 100644 index 000000000..4e98b3639 --- /dev/null +++ b/desktop/frontend/scripts/check-browser-preview-stability.mjs @@ -0,0 +1,385 @@ +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, ".."); + +function read(rel) { + return fs.readFileSync(path.join(frontendRoot, rel), "utf8"); +} + +function fail(message) { + console.error(message); + process.exitCode = 1; +} + +function mustNotContain(rel, source, needle, reason) { + if (source.includes(needle)) { + fail(`${rel}: found forbidden "${needle}" (${reason})`); + } +} + +function mustContain(rel, source, needle, reason) { + if (!source.includes(needle)) { + fail(`${rel}: missing "${needle}" (${reason})`); + } +} + +function mustNotMatch(rel, source, pattern, reason) { + if (pattern.test(source)) { + fail(`${rel}: matched forbidden pattern ${pattern} (${reason})`); + } +} + +const anchoredPopover = read("src/components/AnchoredPopover.tsx"); +mustNotContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "anchored-popover__backdrop", + "non-modal popovers must not render a transparent page-covering click layer", +); +mustNotContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "document.querySelector(\"[data-anchored-popover='active']\")", + "popover positioning must measure its own ref, not the first matching portal in the document", +); +mustContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "const popoverRef = useRef(null);", + "popover positioning and outside-click handling must be scoped to the current instance", +); +mustContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "popoverRef.current?.getBoundingClientRect()", + "popover positioning must use the current popover instance", +); + +const statusBar = read("src/components/StatusBar.tsx"); +mustNotContain( + "src/components/StatusBar.tsx", + statusBar, + "modelsw__backdrop", + "status-bar popovers must not cover the whole viewport after opening", +); +mustContain( + "src/components/StatusBar.tsx", + statusBar, + "wrapRef.current?.contains(target)", + "status-bar popover outside-click handling must be scoped to the wrapper", +); + +const composer = read("src/components/Composer.tsx"); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-action-trigger", + "more actions must live behind the compact plus affordance", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-mode-chip--plan", + "active plan mode must surface as a dismissible composer chip", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-mode-chip--goal", + "active goal mode must surface as a dismissible composer chip", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-intent-menu__item${planModeOn", + "plan mode must be created from the plus menu", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-modebar--approval", + "tool approval must use a direct segmented control", +); +for (const mode of ["ask", "auto", "yolo"]) { + mustNotMatch( + "src/components/Composer.tsx", + composer, + new RegExp(`composer-modebar__item--${mode}[\\s\\S]{0,320}disabled=\\{disabled \\|\\| running\\}`), + "approval mode must remain switchable while a model turn is running", + ); +} +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-modebar--collaboration", + "collaboration modes must not occupy the always-visible segmented control", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-intent-chip", + "plan/goal chips must not return to the always-visible composer row", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-plan-toggle", + "plan mode must not be an always-visible standalone control", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-access-trigger", + "tool approval must not regress to a single dropdown trigger", +); + +const projectTree = read("src/components/ProjectTree.tsx"); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "GLOBAL_PROJECT_ORDER_KEY", + "Global must participate in project tree reorder through a stable virtual key", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "draggable={draggableProject}", + "project rows should be directly draggable for reorder", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "target.closest(\".project-tree__action-slot\")", + "project row drag must not start from the new-topic action area", +); +mustNotContain( + "src/components/ProjectTree.tsx", + projectTree, + "project-tree__drag-handle", + "project reorder should not expose a separate drag handle", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "manuallyCollapsedRef", + "project-tree async refresh must read the latest manual collapse state", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "project-tree__collapse-all", + "project tree needs a dedicated collapse-all affordance", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "collapseSnapshot", + "collapse-all must be reversible to the previous tree view", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "collapsibleFolderKeys", + "collapse-all must target every expandable project folder key", +); + +const styles = read("src/styles.css"); +mustNotContain( + "src/styles.css", + styles, + ".anchored-popover__backdrop", + "stale backdrop CSS can hide accidental page-covering layers", +); +mustNotContain( + "src/styles.css", + styles, + ".modelsw__backdrop", + "stale backdrop CSS can hide accidental page-covering layers", +); +mustNotContain( + "src/styles.css", + styles, + ".composer-access-trigger", + "stale single approval dropdown CSS should not survive the segmented control migration", +); +mustContain( + "src/styles.css", + styles, + ".composer-modebar--approval", + "approval segmented control needs dedicated state styling", +); +mustContain( + "src/styles.css", + styles, + ".composer-mode-chip", + "active plan/goal chips need shared dismiss styling", +); +mustContain( + "src/styles.css", + styles, + ".composer-intent-switch", + "plan/goal plus menu needs explicit switch feedback", +); +mustNotContain( + "src/styles.css", + styles, + ".project-tree__drag-handle", + "project reorder should not expose a separate drag handle", +); +mustContain( + "src/styles.css", + styles, + ".project-tree__folder--draggable", + "project reorder needs whole-row drag cursor feedback", +); +mustContain( + "src/styles.css", + styles, + ".project-tree__collapse-all", + "project collapse-all button must keep its compact icon affordance", +); +mustContain( + "src/styles.css", + styles, + ".project-tree__collapse-all--restore", + "restorable project tree state must have a distinct visual affordance", +); +mustContain( + "src/styles.css", + styles, + "grid-template-rows 0.2s cubic-bezier", + "project folder collapse needs an intentional height transition", +); + +const app = read("src/App.tsx"); +mustContain( + "src/App.tsx", + app, + "const [rightDockMode, setRightDockMode] = useState(\"context\");", + "the right dock should open on overview as the first information layer", +); +mustContain( + "src/App.tsx", + app, + "openWorkspacePanel(\"context\");", + "reopening the right dock should return to the overview entry point", +); +mustContain( + "src/App.tsx", + app, + "{SHOW_CONTEXT_DOCK && (\n runningMock && mockTopicIsRunning(topicId);", + "all mock runtime state must stay behind the explicit running scenario", +); +mustContain( + "src/lib/bridge.ts", + bridge, + "if (!runningMock) return;", + "mock runtime event injection must be disabled for the default browser preview", +); +mustNotContain( + "src/lib/bridge.ts", + bridge, + "running: mockTopicIsRunning(", + "tab running state must not bypass the explicit running scenario", +); +for (const status of ["streaming", "thinking", "waiting_confirmation"]) { + mustContain( + "src/lib/bridge.ts", + bridge, + `status: runningMock ? "${status}"`, + `mock ${status} state must not appear in the default browser preview`, + ); +} +mustContain( + "src/lib/bridge.ts", + bridge, + "running: runningMock && mockTopicIsRunning(\"topic_p3b_pd\")", + "mock tab running state must not be active in the default browser preview", +); + +if (process.exitCode) { + process.exit(process.exitCode); +} + +console.log("Browser preview stability check passed"); diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 66dae8311..a27fd57c6 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1,23 +1,19 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; import { + Activity, + Command, Download, SquarePen, - CircleGauge, FileText, FileJson, GitBranch, History, Settings as SettingsIcon, Pencil, - PanelLeftClose, - PanelLeftOpen, - PanelRightClose, - PanelRightOpen, Trash2, } from "lucide-react"; -import logoWordmark from "./assets/logo-wordmark.svg"; import { useToast } from "./lib/toast"; import { asArray } from "./lib/array"; import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT } from "./lib/i18n"; @@ -28,8 +24,10 @@ import { Composer } from "./components/Composer"; import { TodoPanel } from "./components/TodoPanel"; import { ApprovalModal } from "./components/ApprovalModal"; import { AskCard } from "./components/AskCard"; +import { ClearContextCard } from "./components/ClearContextCard"; import { StatusBar } from "./components/StatusBar"; import { HistoryPanel } from "./components/HistoryPanel"; +import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; import { SettingsPanel } from "./components/SettingsPanel"; import { UpdateBanner } from "./components/UpdateBanner"; import { ContextPanel } from "./components/ContextPanel"; @@ -37,13 +35,33 @@ import { WorkspacePanel } from "./components/WorkspacePanel"; import { Tooltip } from "./components/Tooltip"; import { StartupSplash, shouldShowStartupSplash } from "./components/StartupSplash"; import { OnboardingOverlay } from "./components/OnboardingOverlay"; -import { TabBar } from "./components/TabBar"; +import { AppChrome } from "./components/AppChrome"; import { ProjectTree } from "./components/ProjectTree"; import { CopyButton } from "./components/CopyButton"; -import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; import { parseTodos } from "./lib/tools"; import { shouldShowTodoPanel } from "./lib/todoVisibility"; -import type { ComposerInsertRequest, Meta, Mode, SessionMeta, SettingsTab, TabMeta } from "./lib/types"; +import { + modeHasAutoApproveTools, + modeHasPlan, + modeFromAxes, + normalizeMode, + normalizeToolApprovalMode, + type CollaborationMode, + type ComposerInsertRequest, + type Mode, + type ProjectNode, + type SessionMeta, + type SettingsTab, + type TabMeta, + type ToolApprovalMode, +} from "./lib/types"; +import { + controllerCollaborationMode, + displayedCollaborationMode, + keepGoalDraftMode, + metaSyncedCollaborationMode, + tabListCollaborationMode, +} from "./lib/goalDraftMode"; import { loadLayoutSize, saveLayoutSize } from "./lib/layoutPreferences"; import { applyTheme, @@ -54,35 +72,33 @@ import { normalizeThemePreference, normalizeThemeStyleForTheme, readLegacyThemePreference, - themeForStyle, type Theme, } from "./lib/theme"; import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize"; import { useWindowStatePersistence } from "./lib/windowState"; +import logoWordmark from "./assets/logo-wordmark.svg"; const SIDEBAR_COLLAPSED_KEY = "reasonix.sidebar.collapsed"; const SIDEBAR_DEFAULT_WIDTH = 264; -const SIDEBAR_DEFAULT_RATIO = 0.175; -const SIDEBAR_MIN_WIDTH = 228; -const SIDEBAR_MAX_WIDTH = 420; +const SIDEBAR_MIN_WIDTH = 248; +const SIDEBAR_MAX_WIDTH = 300; +const SIDEBAR_VIEWPORT_RATIO = 0.18; const CHAT_MIN_WIDTH = 400; +const CHAT_DOCKED_MIN_WIDTH = 640; const WORKSPACE_RESIZER_WIDTH = 8; function isThemeMode(value: string): value is Theme { return value === "auto" || value === "light" || value === "dark"; } -const CONTEXT_PANEL_MIN_WIDTH = 340; -const RIGHT_DOCK_MIN_WIDTH = CONTEXT_PANEL_MIN_WIDTH; -const RIGHT_DOCK_CONTEXT_WIDTH = 380; -const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 320; -const RIGHT_DOCK_TREE_DEFAULT_RATIO = 0.25; -const RIGHT_DOCK_TREE_MIN_WIDTH = 260; +const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 300; +const RIGHT_DOCK_TREE_MIN_WIDTH = 300; const RIGHT_DOCK_TREE_MAX_WIDTH = 560; -const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 640; +const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 660; +const RIGHT_DOCK_PREVIEW_MIN_WIDTH = RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH; const RIGHT_DOCK_MAX_WIDTH = 860; type RightDockMode = "context" | "files" | "changed"; -const SHOW_CONTEXT_DOCK = false; +const SHOW_CONTEXT_DOCK = true; type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string }; type DesktopPlatform = "darwin" | "windows" | "linux"; type HistoryViewState = @@ -94,37 +110,23 @@ function clampSidebarWidth(width: number): number { return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width))); } -function clampRightDockWidth(width: number): number { - return Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(RIGHT_DOCK_MIN_WIDTH, Math.round(width))); +function clampRightDockPreviewWidth(width: number): number { + return Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width))); } function clampRightDockTreeWidth(width: number): number { return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width))); } -function viewportWidthFallback(): number { - if (typeof window === "undefined") return 0; - const width = Math.round(window.innerWidth || 0); - return Number.isFinite(width) && width > 0 ? width : 0; -} - function defaultSidebarWidth(): number { - const width = viewportWidthFallback(); - if (width <= 0) return SIDEBAR_DEFAULT_WIDTH; - return clampSidebarWidth(width * SIDEBAR_DEFAULT_RATIO); + if (typeof window !== "undefined") { + return clampSidebarWidth(window.innerWidth * SIDEBAR_VIEWPORT_RATIO); + } + return SIDEBAR_DEFAULT_WIDTH; } function defaultRightDockTreeWidth(): number { - const width = viewportWidthFallback(); - if (width <= 0) return RIGHT_DOCK_TREE_DEFAULT_WIDTH; - return clampRightDockTreeWidth(width * RIGHT_DOCK_TREE_DEFAULT_RATIO); -} - -function resolveRightDockWidth(mainWidth: number, desiredDockWidth: number, minWidth: number): number { - const budget = Math.max(0, Math.round(mainWidth) - CHAT_MIN_WIDTH - WORKSPACE_RESIZER_WIDTH); - if (budget < minWidth) return 0; - const desired = Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(minWidth, Math.round(desiredDockWidth))); - return Math.min(budget, desired); + return RIGHT_DOCK_TREE_DEFAULT_WIDTH; } function loadSidebarCollapsed(): boolean { @@ -146,11 +148,11 @@ function saveSidebarCollapsed(collapsed: boolean): void { } function loadSidebarWidth(): number { - return loadLayoutSize("sidebarWidth", defaultSidebarWidth(), clampSidebarWidth); + return loadLayoutSize("sidebarWidthGraphite", defaultSidebarWidth(), clampSidebarWidth); } function saveSidebarWidth(width: number): void { - saveLayoutSize("sidebarWidth", width, clampSidebarWidth); + saveLayoutSize("sidebarWidthGraphite", width, clampSidebarWidth); } function normalizeDesktopPlatform(value: string): DesktopPlatform { @@ -184,11 +186,11 @@ function saveRightDockTreeWidth(width: number): void { } function loadRightDockPreviewWidth(): number { - return loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH, clampRightDockWidth); + return loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH, clampRightDockPreviewWidth); } function saveRightDockPreviewWidth(width: number): void { - saveLayoutSize("rightDockPreviewWidth", width, clampRightDockWidth); + saveLayoutSize("rightDockPreviewWidth", width, clampRightDockPreviewWidth); } function tabWorkspaceTitle(tab?: TabMeta): string { @@ -205,21 +207,17 @@ function topicTitle(tab?: TabMeta): string { 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 topicScopeLabel(tab?: TabMeta): string { if (!tab) return t("scope.global"); if (tab.scope === "global") return tab.workspaceName || t("scope.global"); return t("scope.project", { name: tab.workspaceName || tab.workspaceRoot || "Project" }); } -function appChromeScopeLabel(tab?: TabMeta, meta?: Meta): string { - if (tab?.scope === "project" || tab?.scope === "global") return tabWorkspaceTitle(tab); - return workspaceDisplayName(meta?.cwd) || meta?.label || "Global"; -} - -function normalizeModeValue(mode?: string): Mode { - return mode === "plan" || mode === "yolo" ? mode : "normal"; -} - function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] { if (filter.scope === "project") { return sessions.filter((session) => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot); @@ -227,6 +225,23 @@ function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): return sessions.filter((session) => (session.scope || "global") === "global"); } +function activeTopicTurnsFromTree(nodes: ProjectNode[], tab?: TabMeta): number | undefined { + if (!tab?.topicId) return undefined; + const activeScope = tab.scope === "global" ? "global" : "project"; + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.shift(); + if (!node) continue; + if (node.kind === "topic" || node.kind === "global_topic") { + const nodeScope = node.kind === "global_topic" ? "global" : "project"; + const sameRoot = nodeScope === "global" || node.root === tab.workspaceRoot; + if (node.topicId === tab.topicId && nodeScope === activeScope && sameRoot) return node.turns ?? 0; + } + if (node.children?.length) stack.push(...node.children); + } + return undefined; +} + function workspaceDisplayName(path?: string): string { if (!path) return ""; const parts = path.split(/[/\\]/).filter(Boolean); @@ -371,7 +386,11 @@ export default function App() { approve, answerQuestion, setControllerMode, - newSession, + setCollaborationMode: setControllerCollaborationMode, + setToolApprovalMode: setControllerToolApprovalMode, + setGoal: setControllerGoal, + clearGoal: clearControllerGoal, + clearSession, listSessions, listTrashedSessions, resumeSession, @@ -396,6 +415,10 @@ export default function App() { const { locale, setPref: setLocalePref } = useI18n(); const t = useT(); const [modesByTab, setModesByTab] = useState>({}); + const [collaborationModesByTab, setCollaborationModesByTab] = useState>({}); + const [toolApprovalModesByTab, setToolApprovalModesByTab] = useState>({}); + const [goalsByTab, setGoalsByTab] = useState>({}); + const [goalDraftModesByTab, setGoalDraftModesByTab] = useState>({}); const [tabMetas, setTabMetas] = useState([]); const [tabOrderIds, setTabOrderIds] = useState([]); const [tabRevealSignal, setTabRevealSignal] = useState(0); @@ -405,6 +428,8 @@ export default function App() { const [needsOnboarding, setNeedsOnboarding] = useState(null); const [settingsTarget, setSettingsTarget] = useState(null); const [histView, setHistView] = useState(null); + const [paletteOpen, setPaletteOpen] = useState(false); + const [paletteSessions, setPaletteSessions] = useState([]); const { showToast } = useToast(); const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); @@ -413,23 +438,82 @@ export default function App() { const [rightDockTreeWidth, setRightDockTreeWidth] = useState(loadRightDockTreeWidth); const [rightDockPreviewWidth, setRightDockPreviewWidth] = useState(loadRightDockPreviewWidth); const [workspacePreviewActive, setWorkspacePreviewActive] = useState(false); + const [contextDetailActive, setContextDetailActive] = useState(false); const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false); const [workspacePanelMaximized, setWorkspacePanelMaximized] = useState(false); - const [rightDockMode, setRightDockMode] = useState("files"); + const [rightDockMode, setRightDockMode] = useState("context"); const [dockRefreshKey, setDockRefreshKey] = useState(0); const [projectRevision, setProjectRevision] = useState(0); const [composerInsertRequest, setComposerInsertRequest] = useState(null); + const [transientOverlayDismissSignal, setTransientOverlayDismissSignal] = useState(0); const [desktopPlatform, setDesktopPlatform] = useState(detectBrowserPlatform); const [renamingTopicId, setRenamingTopicId] = useState(null); const [topicTitleDraft, setTopicTitleDraft] = useState(""); const [topicExportOpen, setTopicExportOpen] = useState(false); - const [paletteOpen, setPaletteOpen] = useState(false); + const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false); + const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false); + const [savedTopicTurnCount, setSavedTopicTurnCount] = useState(undefined); + const [clearContextPending, setClearContextPending] = useState(false); const topicRenameSkipCommitRef = useRef(false); const topicRenameCommitHandledRef = useRef(false); + const appRef = useRef(null); + const sidebarTogglePressTimerRef = useRef(null); + const workspaceTogglePressTimerRef = useRef(null); // Persist window geometry across launches. useWindowStatePersistence(); + const closeTransientOverlays = useCallback(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }, []); + + 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(); @@ -480,40 +564,47 @@ export default function App() { useEffect(() => { if (typeof window === "undefined" || !window.runtime) return; return window.runtime.EventsOn("app:open-settings", () => { + closeTransientOverlays(); setSettingsTarget("general"); }); - }, []); + }, [closeTransientOverlays]); const [pendingPlanRevision, setPendingPlanRevision] = useState(null); const [footerHeight, setFooterHeight] = useState(0); - const layoutRef = useRef(null); + const footerHeightRef = useRef(0); const footerRef = useRef(null); - const [layoutWidth, setLayoutWidth] = useState(0); - const preferredWorkspacePanelWidth = - rightDockMode === "context" - ? RIGHT_DOCK_CONTEXT_WIDTH - : workspacePreviewActive - ? rightDockPreviewWidth - : rightDockTreeWidth; - const sidebarRenderWidth = sidebarCollapsed ? 0 : sidebarWidth; - const measuredMainWidth = layoutWidth > 0 ? Math.max(0, layoutWidth - sidebarRenderWidth) : CHAT_MIN_WIDTH + WORKSPACE_RESIZER_WIDTH + preferredWorkspacePanelWidth; - const workspacePanelMinWidth = workspacePreviewActive ? RIGHT_DOCK_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; - - const budget = Math.max(0, measuredMainWidth - CHAT_MIN_WIDTH - WORKSPACE_RESIZER_WIDTH); - const workspacePanelFloating = workspacePanelOpen && !workspacePanelMaximized && budget < workspacePanelMinWidth; + const rightDockDetailActive = rightDockMode === "context" ? contextDetailActive : workspacePreviewActive; + const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth; + const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; const resolvedWorkspacePanelWidth = workspacePanelOpen && !workspacePanelMaximized - ? (workspacePanelFloating ? Math.min(measuredMainWidth, Math.max(workspacePanelMinWidth, preferredWorkspacePanelWidth)) : resolveRightDockWidth(measuredMainWidth, preferredWorkspacePanelWidth, workspacePanelMinWidth)) + ? Math.max(workspacePanelMinWidth, preferredWorkspacePanelWidth) : preferredWorkspacePanelWidth; const workspacePanelRenderable = workspacePanelOpen && (workspacePanelMaximized || resolvedWorkspacePanelWidth > 0); - const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized && !workspacePanelFloating; + const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized; const workspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth; const activeTab = useMemo( () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), [activeTabId, tabMetas], ); const startupSplashHold = state.meta?.ready !== true && !state.meta?.startupErr; - const mode = activeTabId ? modesByTab[activeTabId] ?? "normal" : "normal"; + const legacyMode = activeTabId ? modesByTab[activeTabId] ?? "normal" : "normal"; + const goal = activeTabId ? goalsByTab[activeTabId] ?? state.meta?.goal ?? activeTab?.goal ?? "" : ""; + const goalDraftMode = activeTabId ? Boolean(goalDraftModesByTab[activeTabId]) : false; + const collaborationMode = activeTabId + ? displayedCollaborationMode({ + goalDraftMode, + localMode: collaborationModesByTab[activeTabId], + metaGoal: state.meta?.goal, + tabMode: activeTab?.collaborationMode, + goal, + legacyMode, + }) + : "normal"; + const toolApprovalMode = activeTabId + ? toolApprovalModesByTab[activeTabId] ?? normalizeToolApprovalMode(state.meta?.toolApprovalMode ?? activeTab?.toolApprovalMode, legacyMode, state.meta?.autoApproveTools ?? state.meta?.bypass) + : "ask"; + const controllerReady = state.meta?.ready === true; const setMode = useCallback( (next: Mode | ((prev: Mode) => Mode)) => { if (!activeTabId) return; @@ -526,8 +617,16 @@ export default function App() { }, [activeTabId], ); + const setGoalDraftModeForTab = useCallback((tabId: string, enabled: boolean) => { + setGoalDraftModesByTab((current) => { + if (Boolean(current[tabId]) === enabled) return current; + if (enabled) return { ...current, [tabId]: true }; + const next = { ...current }; + delete next[tabId]; + return next; + }); + }, []); const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId); - const topicbarProjectPrefix = activeTab ? tabWorkspaceTitle(activeTab) : ""; const visibleTabId = activeTabId; const visibleTabs = useMemo(() => { const byId = new Map(tabMetas.map((tab) => [tab.id, tab])); @@ -535,10 +634,20 @@ export default function App() { const missing = tabMetas.filter((tab) => !tabOrderIds.includes(tab.id)); return [...ordered, ...missing].map((tab) => ({ ...tab, - mode: modesByTab[tab.id] ?? normalizeModeValue(tab.mode), + running: tab.id === visibleTabId ? tab.running || state.running : tab.running, + mode: modesByTab[tab.id] ?? normalizeMode(tab.mode), + collaborationMode: tabListCollaborationMode({ + goalDraftMode: Boolean(goalDraftModesByTab[tab.id]), + localMode: collaborationModesByTab[tab.id], + tabMode: tab.collaborationMode, + tabGoal: goalsByTab[tab.id] ?? tab.goal, + legacyMode: normalizeMode(tab.mode), + }), + toolApprovalMode: toolApprovalModesByTab[tab.id] ?? normalizeToolApprovalMode(tab.toolApprovalMode, normalizeMode(tab.mode), tab.toolApprovalMode === "yolo"), + goal: goalsByTab[tab.id] ?? tab.goal ?? "", active: tab.id === visibleTabId, })); - }, [modesByTab, tabMetas, tabOrderIds, visibleTabId]); + }, [collaborationModesByTab, goalDraftModesByTab, goalsByTab, modesByTab, state.running, tabMetas, tabOrderIds, toolApprovalModesByTab, visibleTabId]); useEffect(() => { const ids = tabMetas.map((tab) => tab.id); @@ -553,11 +662,26 @@ export default function App() { useEffect(() => { const ids = new Set(tabMetas.map((tab) => tab.id)); + setGoalDraftModesByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + if (keepGoalDraftMode(Boolean(current[tab.id]), tab.goal)) { + next[tab.id] = true; + } else if (current[tab.id]) { + changed = true; + } + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); setModesByTab((current) => { let changed = false; const next: Record = {}; for (const tab of tabMetas) { - const mode = normalizeModeValue(tab.mode); + const mode = normalizeMode(tab.mode); next[tab.id] = mode; if (current[tab.id] !== mode) changed = true; } @@ -566,7 +690,51 @@ export default function App() { } return changed ? next : current; }); - }, [tabMetas]); + setCollaborationModesByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + const value = tabListCollaborationMode({ + goalDraftMode: keepGoalDraftMode(Boolean(goalDraftModesByTab[tab.id]), tab.goal), + tabMode: tab.collaborationMode, + tabGoal: tab.goal, + legacyMode: normalizeMode(tab.mode), + }); + next[tab.id] = value; + if (current[tab.id] !== value) changed = true; + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); + setToolApprovalModesByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + const value = normalizeToolApprovalMode(tab.toolApprovalMode, normalizeMode(tab.mode)); + next[tab.id] = value; + if (current[tab.id] !== value) changed = true; + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); + setGoalsByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + const value = tab.goal ?? ""; + next[tab.id] = value; + if (current[tab.id] !== value) changed = true; + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); + }, [goalDraftModesByTab, tabMetas]); useEffect(() => { if (!renamingTopicId || activeTab?.topicId === renamingTopicId) return; @@ -576,6 +744,17 @@ export default function App() { setTopicTitleDraft(""); }, [activeTab?.topicId, renamingTopicId]); + useEffect(() => { + if (!activeTabId || !state.meta) return; + const nextGoal = state.meta.goalStatus === "running" ? state.meta.goal ?? "" : ""; + if (nextGoal) setGoalDraftModeForTab(activeTabId, false); + setGoalsByTab((current) => (current[activeTabId] === nextGoal ? current : { ...current, [activeTabId]: nextGoal })); + setCollaborationModesByTab((current) => { + const nextMode = metaSyncedCollaborationMode({ nextGoal, goalDraftMode, legacyMode }); + return current[activeTabId] === nextMode ? current : { ...current, [activeTabId]: nextMode }; + }); + }, [activeTabId, goalDraftMode, legacyMode, setGoalDraftModeForTab, state.meta]); + const syncModeToController = useCallback((m: Mode) => setControllerMode(m), [setControllerMode]); useEffect(() => { @@ -584,18 +763,79 @@ export default function App() { // 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 every tool call). normal clears both. + // only; yolo = auto-approve approval-gated tools while user decisions still wait). + // normal clears both. const applyMode = useCallback( (m: Mode) => { + if (!activeTabId) return; + const nextCollaborationMode: CollaborationMode = modeHasPlan(m) ? "plan" : "normal"; + const nextToolApprovalMode: ToolApprovalMode = modeHasAutoApproveTools(m) ? "yolo" : "ask"; + setGoalDraftModeForTab(activeTabId, false); setMode(m); + setCollaborationModesByTab((current) => (current[activeTabId] === nextCollaborationMode ? current : { ...current, [activeTabId]: nextCollaborationMode })); + setToolApprovalModesByTab((current) => (current[activeTabId] === nextToolApprovalMode ? current : { ...current, [activeTabId]: nextToolApprovalMode })); + setGoalsByTab((current) => (current[activeTabId] ? { ...current, [activeTabId]: "" } : current)); void syncModeToController(m); }, - [setMode, syncModeToController], + [activeTabId, setGoalDraftModeForTab, setMode, syncModeToController], + ); + const applyCollaborationMode = useCallback( + (m: CollaborationMode) => { + if (!activeTabId) return; + if (m === "goal") { + setGoalDraftModeForTab(activeTabId, true); + setCollaborationModesByTab((current) => (current[activeTabId] === "goal" ? current : { ...current, [activeTabId]: "goal" })); + setMode(modeFromAxes(false, toolApprovalMode === "yolo")); + void setControllerCollaborationMode("normal"); + return; + } + setGoalDraftModeForTab(activeTabId, false); + setCollaborationModesByTab((current) => (current[activeTabId] === m ? current : { ...current, [activeTabId]: m })); + if (m === "normal" || m === "plan") { + setGoalsByTab((current) => (current[activeTabId] ? { ...current, [activeTabId]: "" } : current)); + } + setMode(modeFromAxes(m === "plan", toolApprovalMode === "yolo")); + void setControllerCollaborationMode(m); + }, + [activeTabId, setControllerCollaborationMode, setGoalDraftModeForTab, setMode, toolApprovalMode], + ); + const applyToolApprovalMode = useCallback( + (m: ToolApprovalMode) => { + if (!activeTabId) return; + setToolApprovalModesByTab((current) => (current[activeTabId] === m ? current : { ...current, [activeTabId]: m })); + setMode(modeFromAxes(collaborationMode === "plan", m === "yolo")); + void setControllerToolApprovalMode(m); + }, + [activeTabId, collaborationMode, setControllerToolApprovalMode, setMode], + ); + const applyGoal = useCallback( + (nextGoal: string) => { + if (!activeTabId) return; + const trimmed = nextGoal.trim(); + setGoalDraftModeForTab(activeTabId, false); + setGoalsByTab((current) => (current[activeTabId] === trimmed ? current : { ...current, [activeTabId]: trimmed })); + setCollaborationModesByTab((current) => { + const nextMode = trimmed ? "goal" : "normal"; + return current[activeTabId] === nextMode ? current : { ...current, [activeTabId]: nextMode }; + }); + setMode(modeFromAxes(false, toolApprovalMode === "yolo")); + void (trimmed ? setControllerGoal(trimmed) : clearControllerGoal()); + }, + [activeTabId, clearControllerGoal, setControllerGoal, setGoalDraftModeForTab, setMode, toolApprovalMode], ); - // Shift+Tab cycles auto(normal) → plan → yolo → auto. + const startGoal = useCallback( + (nextGoal: string) => { + const trimmed = nextGoal.trim(); + if (!trimmed) return; + applyGoal(trimmed); + send(trimmed, `/goal ${trimmed}`); + }, + [applyGoal, send], + ); + // Shift+Tab toggles only the collaboration axis; tool permission stays independent. const cycleMode = useCallback(() => { - applyMode(mode === "normal" ? "plan" : mode === "plan" ? "yolo" : "normal"); - }, [mode, applyMode]); + 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 @@ -603,19 +843,23 @@ export default function App() { const switchModel = useCallback( async (name: string) => { await setModel(name); - await syncModeToController(mode); + await setControllerCollaborationMode(controllerCollaborationMode({ collaborationMode, goal })); + await setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) await setControllerGoal(goal); }, - [setModel, mode, syncModeToController], + [collaborationMode, 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 SetBypass was a - // harmless no-op. + // where the user picked YOLO while boot was still loading and the legacy + // SetBypass binding was a harmless no-op. useEffect(() => { - if (state.meta?.ready !== true || mode === "normal") return; - void syncModeToController(mode); - }, [state.meta, mode, syncModeToController]); + if (!controllerReady) return; + void setControllerCollaborationMode(controllerCollaborationMode({ collaborationMode, goal })); + void setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) void setControllerGoal(goal); + }, [collaborationMode, 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 @@ -637,13 +881,10 @@ export default function App() { const [dismissedTodo, setDismissedTodo] = useState(null); const showTodos = shouldShowTodoPanel(todoItem?.id, dismissedTodo, todos); - // useDeferredValue lets React prioritise Composer input (high-priority) over - // Transcript re-renders (low-priority) during streaming. When a keystroke - // and a transcript update collide, the keystroke is processed immediately - // and the transcript re-render is deferred to idle time. - const deferredItems = useDeferredValue(state.items); const sessionTitle = topicTitle(activeTab); const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning); + const transcriptTurnCount = useMemo(() => state.items.reduce((count, item) => count + (item.kind === "user" ? 1 : 0), 0), [state.items]); + const currentTurnCount = Math.max(transcriptTurnCount, savedTopicTurnCount ?? 0); const getSessionMarkdown = useCallback( () => sessionItemsToMarkdown(sessionTitle, state.items, state.live), [sessionTitle, state.items, state.live], @@ -680,9 +921,28 @@ export default function App() { send(text); }, [pendingPlanRevision, send, state.running]); - // handleSend intercepts the slash commands that need a desktop-native action - // before they reach the backend: "/model " rebuilds on that model, and - // "/memory" opens the Memory tab in the settings centre. Everything else — skills (/init, …), + useEffect(() => { + setClearContextPending(false); + }, [activeTabId]); + + const cancelClearContext = useCallback(() => { + setClearContextPending(false); + }, []); + + const confirmClearContext = useCallback(async () => { + setClearContextPending(false); + try { + await clearSession(); + notice(t("clearContext.done")); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + notice(msg || t("clearContext.failed"), "warn"); + } + }, [clearSession, notice, t]); + + // 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). @@ -705,9 +965,30 @@ export default function App() { return; } if (trimmed === "/memory") { + closeTransientOverlays(); setSettingsTarget("memory"); return; } + if (trimmed === "/clear") { + setClearContextPending(true); + return; + } + const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed); + if (goalCommand) { + const arg = (goalCommand[1] ?? "").trim(); + if (arg && !["status", "clear", "off", "stop", "done"].includes(arg.toLowerCase())) { + applyGoal(arg); + } else if (["clear", "off", "stop", "done"].includes(arg.toLowerCase())) { + applyGoal(""); + } + send(trimmed, submitText.trim()); + return; + } + if (collaborationMode === "goal" && !goal.trim()) { + applyGoal(trimmed); + send(trimmed, `/goal ${submitText.trim()}`); + return; + } const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed); if (theme) { const arg = theme[1]?.toLowerCase(); @@ -725,19 +1006,21 @@ export default function App() { return; } if (isThemeStyle(arg)) { - const next = themeForStyle(arg); - await app.SetDesktopAppearance(next, arg); - applyTheme(next, arg); - notice(t("settings.themeChanged", { theme: next, style: arg })); + const cur = getTheme(); + await app.SetDesktopAppearance(cur, arg); + applyTheme(cur, arg); + notice(t("settings.themeChanged", { theme: cur, style: arg })); return; } notice(t("settings.themeUnknown", { name: arg }), "warn"); return; } - await syncModeToController(mode); + await setControllerCollaborationMode(collaborationMode); + await setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) await setControllerGoal(goal); send(trimmed, submitText.trim()); }, - [switchModel, syncModeToController, mode, send, runShell, notice, t], + [applyGoal, closeTransientOverlays, collaborationMode, goal, send, runShell, notice, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, switchModel, t, toolApprovalMode], ); const refreshTabMetas = useCallback(async (): Promise => { @@ -759,6 +1042,24 @@ export default function App() { }); }, [refreshTabMetas]); + useEffect(() => { + let cancelled = false; + if (!activeTab?.topicId) { + setSavedTopicTurnCount(undefined); + return () => { + cancelled = true; + }; + } + void app.ListProjectTree().then((nodes) => { + if (!cancelled) setSavedTopicTurnCount(activeTopicTurnsFromTree(asArray(nodes), activeTab)); + }).catch(() => { + if (!cancelled) setSavedTopicTurnCount(undefined); + }); + return () => { + cancelled = true; + }; + }, [activeTab?.scope, activeTab?.workspaceRoot, activeTab?.topicId, projectRevision]); + useEffect(() => { let cancelled = false; (async () => { @@ -779,48 +1080,47 @@ export default function App() { useEffect(() => { const el = footerRef.current; if (!el || typeof ResizeObserver === "undefined") return; - const update = () => setFooterHeight(Math.round(el.getBoundingClientRect().height)); - update(); - const observer = new ResizeObserver(update); - observer.observe(el); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - const el = layoutRef.current; - if (!el || typeof ResizeObserver === "undefined") return; + let frame = 0; const update = () => { - const width = el.getBoundingClientRect().width; - if (width && Number.isFinite(width)) setLayoutWidth(Math.round(width)); + 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 () => observer.disconnect(); + return () => { + if (frame) window.cancelAnimationFrame(frame); + observer.disconnect(); + }; }, []); - const startNewSession = useCallback(async () => { - await newSession(); - }, [newSession]); - const toggleSidebar = useCallback(() => { - setSidebarCollapsed((collapsed) => { - const next = !collapsed; - saveSidebarCollapsed(next); - return next; - }); - }, []); + closeTransientOverlays(); + pulseSidebarToggle(); + anchorAppScrollToChat(); + const nextCollapsed = !sidebarCollapsed; + setSidebarCollapsed(nextCollapsed); + saveSidebarCollapsed(nextCollapsed); + }, [anchorAppScrollToChat, closeTransientOverlays, pulseSidebarToggle, sidebarCollapsed]); const setExpandedSidebarWidth = useCallback((width: number) => { + closeTransientOverlays(); const next = clampSidebarWidth(width); setSidebarWidth(next); saveSidebarWidth(next); - }, []); + }, [closeTransientOverlays]); const startSidebarResize = useCallback( (event: ReactPointerEvent) => { if (sidebarCollapsed) return; event.preventDefault(); + closeTransientOverlays(); setSidebarResizing(true); let nextWidth = sidebarWidth; const onMove = (moveEvent: PointerEvent) => { @@ -843,7 +1143,7 @@ export default function App() { window.addEventListener("pointerup", onDone); window.addEventListener("pointercancel", onDone); }, - [sidebarCollapsed, sidebarWidth], + [closeTransientOverlays, sidebarCollapsed, sidebarWidth], ); const resizeSidebarWithKeyboard = useCallback( @@ -865,9 +1165,9 @@ export default function App() { const setSavedWorkspacePanelWidth = useCallback( (width: number) => { - if (rightDockMode === "context") return; - if (workspacePreviewActive) { - const next = clampRightDockWidth(width); + closeTransientOverlays(); + if (rightDockDetailActive) { + const next = clampRightDockPreviewWidth(width); setRightDockPreviewWidth(next); saveRightDockPreviewWidth(next); return; @@ -876,23 +1176,25 @@ export default function App() { setRightDockTreeWidth(next); saveRightDockTreeWidth(next); }, - [rightDockMode, workspacePreviewActive], + [closeTransientOverlays, rightDockDetailActive], ); const ensureWorkspacePanelWidth = useCallback( (width: number) => { + closeTransientOverlays(); if (rightDockMode === "context") return; - const next = clampRightDockWidth(width); + const next = clampRightDockPreviewWidth(width); setRightDockPreviewWidth(next); saveRightDockPreviewWidth(next); }, - [rightDockMode], + [closeTransientOverlays, rightDockMode], ); const startWorkspacePanelResize = useCallback( (event: ReactPointerEvent) => { if (!workspacePanelOpen) return; event.preventDefault(); + closeTransientOverlays(); setWorkspacePanelResizing(true); const startX = event.clientX; const startDockWidth = preferredWorkspacePanelWidth; @@ -900,9 +1202,8 @@ export default function App() { const onMove = (moveEvent: PointerEvent) => { const delta = moveEvent.clientX - startX; nextDockWidth = startDockWidth - delta; - if (rightDockMode === "context") return; - if (workspacePreviewActive) { - setRightDockPreviewWidth(clampRightDockWidth(nextDockWidth)); + if (rightDockDetailActive) { + setRightDockPreviewWidth(clampRightDockPreviewWidth(nextDockWidth)); } else { setRightDockTreeWidth(clampRightDockTreeWidth(nextDockWidth)); } @@ -922,7 +1223,7 @@ export default function App() { window.addEventListener("pointerup", onDone); window.addEventListener("pointercancel", onDone); }, - [preferredWorkspacePanelWidth, rightDockMode, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePreviewActive], + [closeTransientOverlays, preferredWorkspacePanelWidth, rightDockDetailActive, setSavedWorkspacePanelWidth, workspacePanelOpen], ); const resizeWorkspacePanelWithKeyboard = useCallback( @@ -932,17 +1233,26 @@ export default function App() { setSavedWorkspacePanelWidth(preferredWorkspacePanelWidth + (event.key === "ArrowLeft" ? 16 : -16)); } else if (event.key === "Home") { event.preventDefault(); - setSavedWorkspacePanelWidth(workspacePreviewActive ? RIGHT_DOCK_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH); + setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH); } else if (event.key === "End") { event.preventDefault(); - setSavedWorkspacePanelWidth(workspacePreviewActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH); + setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH); } }, - [preferredWorkspacePanelWidth, setSavedWorkspacePanelWidth, workspacePreviewActive], + [preferredWorkspacePanelWidth, rightDockDetailActive, setSavedWorkspacePanelWidth], ); const openWorkspacePanel = useCallback( (mode: RightDockMode = rightDockMode) => { + closeTransientOverlays(); + if (mode !== rightDockMode) { + setWorkspacePreviewActive(false); + setContextDetailActive(false); + } else if (mode === "context") { + setWorkspacePreviewActive(false); + } else { + setContextDetailActive(false); + } setRightDockMode(mode); let nextMaximized = workspacePanelMaximized; if (mode === "context") { @@ -961,16 +1271,26 @@ export default function App() { } setWorkspacePanelOpen(true); }, - [rightDockMode, workspacePanelMaximized, workspacePanelOpen], + [closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen], ); const closeWorkspacePanel = useCallback(() => { + closeTransientOverlays(); if (!workspacePanelOpen) { return; } setWorkspacePanelMaximized(false); setWorkspacePanelOpen(false); - }, [workspacePanelOpen]); + }, [closeTransientOverlays, workspacePanelOpen]); + + const toggleWorkspacePanel = useCallback(() => { + pulseWorkspaceToggle(); + if (workspacePanelRenderable) { + closeWorkspacePanel(); + return; + } + openWorkspacePanel("context"); + }, [closeWorkspacePanel, openWorkspacePanel, pulseWorkspaceToggle, workspacePanelRenderable]); const openRightDockMode = useCallback( (mode: RightDockMode) => { @@ -979,11 +1299,30 @@ export default function App() { [openWorkspacePanel], ); + const handleWorkspacePreviewModeChange = useCallback( + (active: boolean) => { + if (workspacePreviewActive === active) return; + closeTransientOverlays(); + setWorkspacePreviewActive(active); + }, + [closeTransientOverlays, workspacePreviewActive], + ); + + const handleContextDetailModeChange = useCallback( + (active: boolean) => { + if (contextDetailActive === active) return; + closeTransientOverlays(); + setContextDetailActive(active); + }, + [closeTransientOverlays, contextDetailActive], + ); + const layoutStyle = useMemo( () => ({ "--sidebar-expanded-width": `${sidebarWidth}px`, "--chat-min-width": `${CHAT_MIN_WIDTH}px`, + "--chat-docked-min-width": `${CHAT_DOCKED_MIN_WIDTH}px`, "--workspace-width": `${workspacePanelRenderWidth}px`, "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, }) as CSSProperties, @@ -1003,12 +1342,14 @@ export default function App() { }, []); const handleTabChange = useCallback(async (id: string) => { + closeTransientOverlays(); await switchTab(id); await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [refreshTabMetas, switchTab]); + }, [closeTransientOverlays, refreshTabMetas, switchTab]); const handleTabClose = useCallback(async (id: string) => { + closeTransientOverlays(); setModesByTab((current) => { if (!(id in current)) return current; const next = { ...current }; @@ -1029,9 +1370,10 @@ export default function App() { await closeTab(id); await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [activeTabId, closeTab, refreshTabMetas]); + }, [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; @@ -1043,7 +1385,7 @@ export default function App() { } await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [closeTab, refreshTabMetas, switchTab, tabMetas]); + }, [closeTab, closeTransientOverlays, refreshTabMetas, switchTab, tabMetas]); const handleTabsReorder = useCallback(async (ids: string[]) => { setTabOrderIds(ids); @@ -1058,9 +1400,10 @@ export default function App() { }, [refreshTabMetas, reorderTabs]); const handleNewTab = useCallback(async () => { - const activeWorkspaceRoot = activeTab?.workspaceRoot || state.meta?.cwd || ""; - const targetScope = activeTab?.scope === "global" || !activeWorkspaceRoot ? "global" : "project"; - const workspaceRoot = targetScope === "project" ? activeWorkspaceRoot : ""; + closeTransientOverlays(); + const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : ""; + const targetScope = activeWorkspaceRoot ? "project" : "global"; + const workspaceRoot = activeWorkspaceRoot; const topic = await app.CreateTopic(targetScope, workspaceRoot, ""); if (targetScope === "global" || !workspaceRoot) { await openGlobalTab(topic.id); @@ -1070,18 +1413,7 @@ export default function App() { setProjectRevision((value) => value + 1); await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [activeTab?.scope, activeTab?.workspaceRoot, openGlobalTab, openProjectTab, refreshTabMetas, state.meta?.cwd]); - - // ── Command palette (⌘K) ──────────────────────────────────────────── - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return; - event.preventDefault(); - setPaletteOpen((open) => !open); - }; - window.addEventListener("keydown", onKeyDown, { capture: true }); - return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); - }, []); + }, [activeTab?.scope, activeTab?.workspaceRoot, closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]); const handleMessageAction = useCallback(async (turn: number, scope: string) => { await rewind(turn, scope); @@ -1098,6 +1430,7 @@ export default function App() { }, [refreshTabMetas, rewind]); const handleOpenTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string) => { + closeTransientOverlays(); if (scope === "global") { await openGlobalTab(topicId); } else { @@ -1105,70 +1438,28 @@ export default function App() { } await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [openGlobalTab, openProjectTab, refreshTabMetas]); + }, [closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]); // 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) }); - }, [listSessions]); + }, [closeTransientOverlays, listSessions]); const openAllHistory = useCallback(async () => { + closeTransientOverlays(); setHistView({ kind: "history", source: "all", sessions: await listSessions() }); - }, [listSessions]); + }, [closeTransientOverlays, listSessions]); const openTrash = useCallback(async () => { + closeTransientOverlays(); setHistView({ kind: "trash", sessions: await listTrashedSessions() }); - }, [listTrashedSessions]); - const closeHistory = useCallback(() => setHistView(null), []); - - // ── Command palette (⌘K) item builder ─────────────────────────────── - const buildPaletteItems = useCallback((): PaletteItem[] => { - const items: PaletteItem[] = []; - - for (const tab of tabMetas) { - items.push({ - id: `tab:${tab.id}`, - title: tab.topicTitle || "Untitled", - hint: tab.scope === "global" ? "Global" : tab.workspaceName || tab.workspaceRoot, - group: t("sidebar.conversations"), - keywords: [tab.label], - run: () => void handleTabChange(tab.id), - }); - } + }, [closeTransientOverlays, listTrashedSessions]); + const closeHistory = useCallback(() => { + closeTransientOverlays(); + setHistView(null); + }, [closeTransientOverlays]); - items.push( - { - id: "action:new-session", - title: t("topbar.newSession"), - group: "Actions", - keywords: ["new", "chat", "session"], - run: () => void handleNewTab(), - }, - { - id: "action:history", - title: t("sidebar.allHistory"), - group: "Actions", - keywords: ["history", "sessions", "past"], - run: () => void openAllHistory(), - }, - { - id: "action:settings", - title: t("topbar.settings"), - group: "Actions", - keywords: ["preferences", "config", "options"], - run: () => setSettingsTarget("general"), - }, - { - id: "action:trash", - title: t("sidebar.trash"), - group: "Actions", - keywords: ["deleted", "bin"], - run: () => void openTrash(), - }, - ); - - return items; - }, [tabMetas, handleTabChange, handleNewTab, openAllHistory, openTrash, t]); const onResumeSession = useCallback( async (session: SessionMeta) => { if (state.running) return; @@ -1200,6 +1491,49 @@ export default function App() { }, [openGlobalTab, openProjectTab, refreshTabMetas, state.running, resumeSession, t, showToast], ); + + // 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]); + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((cur) => { + if (!cur) void openPalette(); + return cur; + }); + } else if (e.key === "Escape") { + setPaletteOpen(false); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [openPalette]); + const paletteItems = useMemo(() => { + const cmds: PaletteItem[] = [ + { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), keywords: ["new", "新建"], run: () => void handleNewTab() }, + { id: "cmd-history", group: t("palette.group.commands"), title: t("palette.cmd.history"), keywords: ["history", "历史"], run: () => void openAllHistory() }, + { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), keywords: ["trash", "回收站"], run: () => void openTrash() }, + { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), keywords: ["settings", "设置"], run: () => setSettingsTarget("general") }, + { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), keywords: ["theme", "appearance", "外观", "主题"], run: () => setSettingsTarget("appearance") }, + { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") }, + { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), keywords: ["model", "模型"], run: () => setSettingsTarget("models") }, + ]; + const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({ + id: `sess-${s.path}`, + group: t("palette.group.sessions"), + title: s.title?.trim() || s.preview || t("history.emptySession"), + hint: s.workspaceRoot || undefined, + 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) => { @@ -1270,12 +1604,6 @@ export default function App() { return picked; }, [pickWorkspace, switchWorkspace, refreshTabMetas]); - const removeWorkspace = useCallback(async (path: string) => { - await app.RemoveWorkspace(path); - setProjectRevision((value) => value + 1); - await refreshTabMetas(); - }, [refreshTabMetas]); - const refreshProjectsAndTabs = useCallback(async () => { setProjectRevision((value) => value + 1); const tabs = await refreshTabMetas(); @@ -1332,26 +1660,28 @@ export default function App() { ? t("sidebar.expand") : t("sidebar.collapse"); const sidebarNavTooltipDisabled = !sidebarCollapsed; - const workspacePanelResetWidth = rightDockMode === "context" - ? RIGHT_DOCK_CONTEXT_WIDTH - : workspacePreviewActive + const browserPreviewChrome = typeof window !== "undefined" && !window.runtime; + const workspacePanelResetWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH : defaultRightDockTreeWidth(); - const workspacePanelMaxWidth = workspacePreviewActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH; + const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH; + const topicbarTitle = topicDisplayTitle(activeTab); + const topicbarWorkspaceLabel = activeTab ? tabWorkspaceTitle(activeTab) : ""; + const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : ""; + const topicbarSubtitleVisible = Boolean(topicbarWorkspaceLabel); + const topicbarSubtitleTitle = topicbarWorkspacePath || topicbarWorkspaceLabel; return ( -
+
-
+ 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()} + /> + +
- -
) : ( - + )} @@ -1605,7 +1934,7 @@ export default function App() { onAnswer={(allow, session, persist, scope) => { // Approving an exit_plan_mode plan leaves plan mode; sync the // tab-local indicator and persisted safe mode immediately. - if (state.approval!.tool === "exit_plan_mode" && allow) applyMode("normal"); + if (state.approval!.tool === "exit_plan_mode" && allow) applyCollaborationMode("normal"); approve(state.approval!.id, allow, session, persist, scope); }} onRevisePlan={(text) => { @@ -1613,7 +1942,7 @@ export default function App() { approve(state.approval!.id, false, false, false); }} onExitPlan={() => { - applyMode("normal"); + applyCollaborationMode("normal"); approve(state.approval!.id, false, false, false); }} /> @@ -1625,9 +1954,19 @@ export default function App() { onDismiss={() => answerQuestion(state.ask!.id, [])} /> )} - { + void confirmClearContext(); + }} + /> + )} + applyGoal("")} onSwitchModel={switchModel} onSetEffort={setEffort} - onPickFolder={switchFolder} - onRemoveWorkspace={removeWorkspace} insertRequest={composerInsertRequest} - disabled={state.meta?.ready === false || state.messageAction != null || state.approval != null || state.ask != null} - decisionPending={state.messageAction != null || state.approval != null || state.ask != null} + disabled={state.meta?.ready === false || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending} + decisionPending={state.messageAction != null || state.approval != null || state.ask != null || clearContextPending} ready={state.meta?.ready === true} turnStartAt={state.turnStartAt} turnTokens={state.turnTokens} retry={state.retry} - workspaceRefreshSignal={projectRevision} + transientDismissSignal={transientOverlayDismissSignal} /> @@ -1687,6 +2031,12 @@ export default function App() { ].join(" ")} aria-label={t("rightDock.workbench")} > +
+
+

{t("workspace.title")}

+
+ +
{SHOW_CONTEXT_DOCK && ( @@ -1697,7 +2047,7 @@ export default function App() { className={`workbench-dock__tab${rightDockMode === "context" ? " workbench-dock__tab--active" : ""}`} onClick={() => openRightDockMode("context")} > - + {t("rightDock.overview")} )} @@ -1733,6 +2083,7 @@ export default function App() { sessionCurrency={state.sessionCurrency} scopeLabel={topicScopeLabel(activeTab)} refreshKey={dockRefreshKey} + onDetailModeChange={handleContextDetailModeChange} /> ) : ( setWorkspacePanel(false)} - onToggleMaximized={() => setWorkspacePanelMaximized((value) => !value)} - onPreviewModeChange={setWorkspacePreviewActive} + onToggleMaximized={() => { + closeTransientOverlays(); + setWorkspacePanelMaximized((value) => !value); + }} + onPreviewModeChange={handleWorkspacePreviewModeChange} onAddToChat={addWorkspaceTextToComposer} onRequestPanelWidth={ensureWorkspacePanelWidth} refreshKey={dockRefreshKey} @@ -1779,19 +2133,19 @@ export default function App() { /> )} + setPaletteOpen(false)} + items={paletteItems} + placeholder={t("palette.placeholder")} + emptyText={t("palette.empty")} + /> + {startupSplashVisible && ( setStartupSplashVisible(false)} /> )} {needsOnboarding && setNeedsOnboarding(false)} />} - - setPaletteOpen(false)} - items={buildPaletteItems()} - placeholder="Quick actions…" - emptyText="No matches" - />
); diff --git a/desktop/frontend/src/__tests__/at-matches.test.ts b/desktop/frontend/src/__tests__/at-matches.test.ts new file mode 100644 index 000000000..c58da9417 --- /dev/null +++ b/desktop/frontend/src/__tests__/at-matches.test.ts @@ -0,0 +1,96 @@ +// Run: tsx src/__tests__/at-matches.test.ts +// +// Regression coverage for the Composer @-menu filter (issue #3769). +// The frontend must mirror the backend fuzzy @-search contract: +// a fragment like "planind" should surface "src/planind/index.tsx" +// because the search results return full slash-normalized relative +// paths, not basenames. + +import { filterAtMatches } from "../lib/atMatches"; +import type { DirEntry } from "../lib/types"; + +let passed = 0; +let failed = 0; + +function eq(a: unknown, b: unknown, label: string) { + if (JSON.stringify(a) === JSON.stringify(b)) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); + failed += 1; + } +} + +function entry(name: string, isDir = false): DirEntry { + return { name, isDir }; +} + +console.log("\nat-matches filter"); + +// 1. Nested file surfaces when fragment matches an intermediate segment. +{ + const entries: DirEntry[] = []; + const searchEntries = [entry("src/planind/index.tsx")]; + const got = filterAtMatches(entries, searchEntries, "planind"); + eq( + got.map((e) => e.name), + ["src/planind/index.tsx"], + "src/planind/index.tsx + planind → surfaces the nested file", + ); +} + +// 2. Basename hit is preserved (regression guard for the legacy v1 path). +{ + const entries: DirEntry[] = []; + const searchEntries = [entry("src/planind/index.tsx")]; + const got = filterAtMatches(entries, searchEntries, "index"); + eq( + got.map((e) => e.name), + ["src/planind/index.tsx"], + "src/planind/index.tsx + index → still surfaces via basename", + ); +} + +// 3. Local ListDir hit and fuzzy Search hit with the same name are de-duped +// to a single entry, with the local one taking precedence (it appears first). +{ + const entries = [entry("planind.go")]; + const searchEntries = [entry("planind.go")]; + const got = filterAtMatches(entries, searchEntries, "planind"); + eq( + got.map((e) => e.name), + ["planind.go"], + "entries ∩ searchEntries with same name dedup to one", + ); +} + +// 4. Local ListDir entries with a matching fragment appear first; fuzzy hits +// fill the rest in the order the backend returned them. +{ + const entries = [entry("planind.go"), entry("planind.md")]; + const searchEntries = [entry("src/planind/index.tsx")]; + const got = filterAtMatches(entries, searchEntries, "planind"); + eq( + got.map((e) => e.name), + ["planind.go", "planind.md", "src/planind/index.tsx"], + "local entries precede fuzzy search hits, no dupes", + ); +} + +// 5. Empty fragment matches every entry because includes("") is true; this +// pins the current behavior so a future change that re-introduces +// basename-only matching or skips empty fragments is caught immediately. +{ + const entries = [entry("a.ts"), entry("b.ts")]; + const searchEntries = [entry("src/c.ts")]; + const got = filterAtMatches(entries, searchEntries, ""); + eq( + got.map((e) => e.name), + ["a.ts", "b.ts", "src/c.ts"], + "empty fragment includes every entry (legacy behavior preserved)", + ); +} + +console.log(`\n${passed} passed, ${failed} failed\n`); +process.exit(failed === 0 ? 0 : 1); diff --git a/desktop/frontend/src/__tests__/goal-draft-mode.test.ts b/desktop/frontend/src/__tests__/goal-draft-mode.test.ts new file mode 100644 index 000000000..16266fdbe --- /dev/null +++ b/desktop/frontend/src/__tests__/goal-draft-mode.test.ts @@ -0,0 +1,75 @@ +// Run: tsx src/__tests__/goal-draft-mode.test.ts + +import { + controllerCollaborationMode, + displayedCollaborationMode, + keepGoalDraftMode, + metaSyncedCollaborationMode, + tabListCollaborationMode, +} from "../lib/goalDraftMode"; + +let passed = 0; +let failed = 0; + +function eq(a: unknown, b: unknown, label: string) { + if (a === b) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); + failed += 1; + } +} + +console.log("\ngoal draft mode"); + +eq( + displayedCollaborationMode({ goalDraftMode: true, localMode: "normal", goal: "" }), + "goal", + "draft goal mode wins over stale local normal mode", +); + +eq( + tabListCollaborationMode({ goalDraftMode: true, tabMode: "normal", tabGoal: "" }), + "goal", + "tab list keeps draft goal mode visible before a goal is started", +); + +eq( + metaSyncedCollaborationMode({ nextGoal: "", goalDraftMode: true, legacyMode: "normal" }), + "goal", + "empty controller meta does not collapse a draft goal mode", +); + +eq( + controllerCollaborationMode({ collaborationMode: "goal", goal: "" }), + "normal", + "empty draft goal syncs to the controller as normal mode", +); + +eq( + controllerCollaborationMode({ collaborationMode: "goal", goal: "ship the fix" }), + "goal", + "started goal syncs to the controller as goal mode", +); + +eq( + keepGoalDraftMode(true, ""), + true, + "draft flag is retained while goal text is empty", +); + +eq( + keepGoalDraftMode(true, "ship the fix"), + false, + "draft flag clears after a real goal exists", +); + +eq( + metaSyncedCollaborationMode({ nextGoal: "", goalDraftMode: false, legacyMode: "plan" }), + "plan", + "non-draft empty goal falls back to legacy plan mode", +); + +console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/math-golden.test.ts b/desktop/frontend/src/__tests__/math-golden.test.ts index e676bde0d..d70bc8980 100644 --- a/desktop/frontend/src/__tests__/math-golden.test.ts +++ b/desktop/frontend/src/__tests__/math-golden.test.ts @@ -133,6 +133,14 @@ eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$"); eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$"); eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected"); +// ── normalizeMath — \slashed conversion (regression) ────────────────────────── +// KaTeX has no \slashed (Feynman slash notation); it is rewritten to \not. +eq(normalizeMath("$\\slashed{p}$"), "$\\not{p}$", "\\slashed{p} → \\not{p}"); +eq(normalizeMath("$\\slashed{\\partial}$"), "$\\not{\\partial}$", "\\slashed{\\partial} → \\not{\\partial}"); +eq(normalizeMath("The momentum $\\slashed{p}$ is conserved"), "The momentum $\\not{p}$ is conserved", "\\slashed in prose"); +eq(normalizeMath("$\\slashed\\epsilon(0)$"), "$\\not{\\epsilon(0)}$", "\\slashed\\epsilon(0) → \\not{\\epsilon(0)} (unbraced fn)"); +eq(normalizeMath("$\\slashed a$"), "$\\not a$", "\\slashed a → \\not a (unbraced letter)"); + console.log("\nnormalizeMath — non-math dollar filtering"); eq(normalizeMath("costs $5$ today"), "costs $5$ today", "$5$ not math"); eq(normalizeMath("env $PATH$ here"), "env $PATH$ here", "$PATH$ not math"); diff --git a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts index 120b8a1fa..77e2fa686 100644 --- a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts +++ b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts @@ -35,6 +35,12 @@ eq( "background refresh does not re-add deleted models", ); +eq( + mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }), + ["mimo-v2.5-pro"], + "manual access refresh preserves selected MiMo model instead of importing provider catalog", +); + eq( mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }), ["coding-pro", "chat"], diff --git a/desktop/frontend/src/components/AnchoredPopover.tsx b/desktop/frontend/src/components/AnchoredPopover.tsx index dd85aef1a..742d584d2 100644 --- a/desktop/frontend/src/components/AnchoredPopover.tsx +++ b/desktop/frontend/src/components/AnchoredPopover.tsx @@ -6,9 +6,11 @@ type PopoverPosition = { left: number; top: number; }; +type PopoverPhase = "closed" | "open" | "closing"; const EDGE_GAP = 8; const DEFAULT_OFFSET = 8; +export const ANCHORED_POPOVER_CLOSE_MS = 140; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); @@ -49,9 +51,10 @@ export function AnchoredPopover({ offset = DEFAULT_OFFSET, placement = "auto", style, + closing = false, }: { open: boolean; - anchorRef: RefObject; + anchorRef: RefObject; onClose: () => void; className: string; children: ReactNode; @@ -59,61 +62,100 @@ export function AnchoredPopover({ offset?: number; placement?: "auto" | "bottom"; style?: CSSProperties; + closing?: boolean; }) { + const [phase, setPhase] = useState(open ? "open" : "closed"); const [position, setPosition] = useState(null); const popoverRef = useRef(null); + const phaseRef = useRef(phase); useLayoutEffect(() => { - if (!open) { + let id: number | undefined; + if (open) { + phaseRef.current = "open"; + setPhase("open"); + return undefined; + } + if (phaseRef.current === "closed") return undefined; + phaseRef.current = "closing"; + setPhase("closing"); + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + id = window.setTimeout(() => { + phaseRef.current = "closed"; + setPhase("closed"); + setPosition(null); + }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS); + return () => { + if (id !== undefined) window.clearTimeout(id); + }; + }, [open]); + + const rendered = closing || phase !== "closed"; + + useLayoutEffect(() => { + if (!rendered) { setPosition(null); return; } - const anchor = anchorRef.current?.getBoundingClientRect(); - const menu = document.querySelector("[data-anchored-popover='active']")?.getBoundingClientRect(); - if (!anchor || !menu) return; - const next = calculatePosition(anchor, menu, align, offset, placement); - setPosition((current) => (samePosition(current, next) ? current : next)); - }, [open, align, offset, placement]); + const updatePosition = () => { + const anchor = anchorRef.current?.getBoundingClientRect(); + const menu = popoverRef.current?.getBoundingClientRect(); + if (!anchor || !menu) return; + const next = calculatePosition(anchor, menu, align, offset, placement); + setPosition((current) => (samePosition(current, next) ? current : next)); + }; + updatePosition(); + const frame = window.requestAnimationFrame(updatePosition); + return () => window.cancelAnimationFrame(frame); + }, [rendered, anchorRef, align, offset, placement]); useEffect(() => { - if (!open) return; + if (!open || closing) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; + const closeOnOutsideClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return; + onClose(); + }; const closeOnViewportChange = () => onClose(); window.addEventListener("keydown", closeOnEscape); + document.addEventListener("click", closeOnOutsideClick); window.addEventListener("resize", closeOnViewportChange); return () => { window.removeEventListener("keydown", closeOnEscape); + document.removeEventListener("click", closeOnOutsideClick); window.removeEventListener("resize", closeOnViewportChange); }; - }, [onClose, open]); + }, [anchorRef, onClose, open]); - if (!open) return null; + if (!rendered) return null; return createPortal( - <> -
-
{ - event.stopPropagation(); - }} - onClick={(event) => { - event.stopPropagation(); - }} - > - {children} -
- , +
{ + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + > + {children} +
, document.body, ); } diff --git a/desktop/frontend/src/components/AppChrome.tsx b/desktop/frontend/src/components/AppChrome.tsx new file mode 100644 index 000000000..d647d44fb --- /dev/null +++ b/desktop/frontend/src/components/AppChrome.tsx @@ -0,0 +1,174 @@ +import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react"; +import { TabBar } from "./TabBar"; +import type { TabMeta } from "../lib/types"; +import { useT } from "../lib/i18n"; + +type DesktopPlatform = "darwin" | "windows" | "linux"; + +interface AppChromeProps { + platform: DesktopPlatform; + browserPreviewChrome: boolean; + tabs: TabMeta[]; + activeTabId?: string; + revealActiveSignal: number; + commandCompact: boolean; + sidebarTogglePressed: boolean; + sidebarExpandBlocked: boolean; + sidebarCollapsed: boolean; + sidebarToggleTitle: string; + workspacePanelMaximized: boolean; + workspacePanelRenderable: boolean; + workspaceTogglePressed: boolean; + workspacePanelLabel: string; + onToggleSidebar: () => void; + onToggleWorkspacePanel: () => void; + onTabChange: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void; + onTabsReorder: (tabIds: string[]) => void; + onNewTab: () => void; + onOpenPalette: () => void; +} + +export function AppChrome({ + platform, + browserPreviewChrome, + tabs, + activeTabId, + revealActiveSignal, + commandCompact, + sidebarTogglePressed, + sidebarExpandBlocked, + sidebarCollapsed, + sidebarToggleTitle, + workspacePanelMaximized, + workspacePanelRenderable, + workspaceTogglePressed, + workspacePanelLabel, + onToggleSidebar, + onToggleWorkspacePanel, + onTabChange, + onTabClose, + onTabsClose, + onTabsReorder, + onNewTab, + onOpenPalette, +}: AppChromeProps) { + const t = useT(); + const darwinChrome = platform === "darwin"; + const detachCommand = !darwinChrome; + const showWindowsPreviewControls = browserPreviewChrome && platform === "windows"; + const chromeClassName = [ + "app-chrome", + "app-chrome--tabs", + darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs", + !darwinChrome ? "app-chrome--identityless" : "", + showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "", + `app-chrome--platform-${platform}`, + ].filter(Boolean).join(" "); + + const tabBar = ( + + ); + + return ( +
+ {browserPreviewChrome && darwinChrome && ( + + )} + + + {darwinChrome ? ( +
+ {tabBar} +
+ ) : ( + <> +
+ {tabBar} +
+ {detachCommand && ( +
+ +
+ )} + + )} + + {!workspacePanelMaximized && ( + + )} + {showWindowsPreviewControls && ( + + )} +
+ ); +} diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index e6726be88..4a9920c6d 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -26,9 +26,7 @@ export function ApprovalModal({ const bashPrefix = isBashApproval ? bashCommandPrefix(subject) : ""; const hasBashPrefix = bashPrefix !== ""; const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? ""; - const exactSessionRule = approvalSessionRule(approval.tool, subject); - const exactPersistentRule = approvalPersistentRule(approval.tool, subject); - const prefixRule = hasBashPrefix ? `Bash(${bashPrefix})` : ""; + const choosePlanAction = (key: string) => { if (key === "1") setRevisionOpen((open) => !open); @@ -154,12 +152,12 @@ export function ApprovalModal({ <> } + label={t("approval.allowRuleSession")} onClick={() => onAnswer(true, true, false, "prefix")} /> } + label={t("approval.allowRulePersistent")} onClick={() => onAnswer(true, true, true, "prefix")} /> onAnswer(false, false, false)} /> @@ -168,12 +166,12 @@ export function ApprovalModal({ <> } + label={t("approval.allowRuleSession")} onClick={() => onAnswer(true, true, false)} /> } + label={t("approval.allowRulePersistent")} onClick={() => onAnswer(true, true, true)} /> onAnswer(false, false, false)} /> @@ -189,26 +187,7 @@ export function ApprovalModal({ ); } -function RuleActionLabel({ label, rule }: { label: string; rule: string }) { - return ( - - {label} - {rule} - - ); -} -function approvalSessionRule(tool: string, subject: string): string { - if (tool === "bash" && subject) return `Bash(${subject})`; - if (isFileMutationTool(tool)) return "Edit"; - return tool; -} - -function approvalPersistentRule(tool: string, subject: string): string { - if (tool === "bash" && subject) return `Bash(${subject})`; - if (isFileMutationTool(tool)) return subject ? `Edit(${subject})` : "Edit"; - return tool; -} function bashCommandPrefix(subject: string): string { const command = subject.trim(); @@ -236,12 +215,3 @@ function dangerousBashCommand(command: string): boolean { || /^dd\s+if=/.test(command) || /^fdisk\b/.test(command); } - -function isFileMutationTool(tool: string): boolean { - return tool === "write_file" - || tool === "edit_file" - || tool === "multi_edit" - || tool === "notebook_edit" - || tool === "delete_range" - || tool === "delete_symbol"; -} diff --git a/desktop/frontend/src/components/CapabilitiesPanel.tsx b/desktop/frontend/src/components/CapabilitiesPanel.tsx index 3ae7665c2..d886e2177 100644 --- a/desktop/frontend/src/components/CapabilitiesPanel.tsx +++ b/desktop/frontend/src/components/CapabilitiesPanel.tsx @@ -6,6 +6,7 @@ import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, import { InlineConfirmButton } from "./InlineConfirmButton"; import { ResizableDrawer } from "./ResizableDrawer"; import { Tooltip } from "./Tooltip"; +import { ModalCloseButton } from "./ModalCloseButton"; // CapabilitiesPanel is the desktop MCP & Skills drawer — the GUI counterpart to // the CLI's /mcp + /skill, aligning with Claude Code's Customize → Connectors: @@ -137,16 +138,14 @@ export function CapabilitiesPanel({
{t("caps.title")}
{view &&
{summary}
}
- - - - - - +
+ + + + +
{!view ? ( diff --git a/desktop/frontend/src/components/ClearContextCard.tsx b/desktop/frontend/src/components/ClearContextCard.tsx new file mode 100644 index 000000000..4dbf2a449 --- /dev/null +++ b/desktop/frontend/src/components/ClearContextCard.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef } from "react"; +import { useT } from "../lib/i18n"; +import { PromptAction, PromptBadge, PromptShelf } from "./PromptShelf"; + +export function ClearContextCard({ + onCancel, + onConfirm, +}: { + onCancel: () => void; + onConfirm: () => void; +}) { + const t = useT(); + const shelfRef = useRef(null); + + useEffect(() => { + shelfRef.current?.focus(); + }, []); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const tag = target?.tagName.toLowerCase(); + if (tag === "input" || tag === "textarea" || target?.isContentEditable) return; + + if (event.key === "Escape" || event.key === "1") { + event.preventDefault(); + onCancel(); + return; + } + if (event.key === "2" || event.key.toLowerCase() === "y") { + event.preventDefault(); + onConfirm(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onCancel, onConfirm]); + + return ( + {t("clearContext.badge")}} + meta={t("clearContext.prompt")} + actions={ + <> + + + + } + > +
+
+ {t("clearContext.detail")} +
+
+
+ ); +} diff --git a/desktop/frontend/src/components/CommandPalette.tsx b/desktop/frontend/src/components/CommandPalette.tsx index 98743fbb3..03b20667b 100644 --- a/desktop/frontend/src/components/CommandPalette.tsx +++ b/desktop/frontend/src/components/CommandPalette.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { Command, Search } from "lucide-react"; +import { useMountTransition } from "../lib/useMountTransition"; // CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's // long-tail navigation surface. Tabs through sessions, slash-commands, and @@ -49,6 +51,9 @@ export function CommandPalette({ const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); + // Keep the palette mounted through its exit animation after `open` flips + // false; `status` drives the enter/exit keyframes via data-state. + const { mounted, status } = useMountTransition(open, 200); // Re-init whenever the palette opens: clear the query, reset the // highlight, and steal focus. Doing it on the open edge (not on every @@ -158,16 +163,22 @@ export function CommandPalette({ return () => document.removeEventListener("keydown", onKey); }, [open, flat, active, onClose]); - if (!open) return null; + if (!mounted) return null; // The running counter maps a flat-index back to its group header so we // can render the section dividers in order. let running = 0; return ( -
-
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={placeholder}> +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={placeholder}>
+