diff --git a/.claude/skills/store-screenshots/SKILL.md b/.claude/skills/store-screenshots/SKILL.md new file mode 100644 index 0000000000..f0c319dd05 --- /dev/null +++ b/.claude/skills/store-screenshots/SKILL.md @@ -0,0 +1,19 @@ +--- +name: store-screenshots +description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed. +user-invocable: true +--- + +# Store screenshots + +Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports. + +Working rules: + +- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store. +- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type. +- Use SF Pro. Never substitute another font. +- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview. +- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks. +- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons. +- When you learn something the next capture needs, fix the README rather than this skill. diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index 833820ff2d..87fc924b6b 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -41,6 +41,7 @@ server: desktop: - "packages/desktop/**" - "packages/app/src/desktop/**" + - "packages/app/src/**/*.electron.{ts,tsx}" - "packages/server/src/server/browser-tools/**" - "packages/app/e2e/support/**" - "packages/app/*config.{cjs,js,ts}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e3f65f336..285ff8e072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,18 @@ name: CI on: push: branches: [main] + # Every base branch, so stacked PRs and PRs into any base get the same checks. pull_request: - branches: [main] merge_group: workflow_dispatch: +# A newer push to the same ref cancels the run still in progress, main included. +# A full run costs about 200 runner-minutes against the org's 20 concurrent +# jobs, and a superseded main run only answers a question the newer commit +# answers again. concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true env: # CI does not use the CUDA execution provider, and the onnxruntime-node @@ -54,10 +58,10 @@ jobs: - name: Validate CI contracts run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs - format: - name: format + quality: + name: quality needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,41 +72,73 @@ jobs: cache: "npm" - name: Install dependencies + id: install run: node scripts/npm-retry.mjs ci + # Explicit status checks keep independent domains running after a failure. + # A failed step still fails quality; no continue-on-error or success shim. - name: Check formatting + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} run: npm run format:check - lint: - name: lint - needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - - - name: Install dependencies - run: node scripts/npm-retry.mjs ci - - name: Lint lockfile + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity - name: Verify dependency signatures + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npm audit signatures - name: Lint + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npm run lint + - name: Test lint rules + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} + run: node --test scripts/oxlint/paseo-plugin.test.mjs + + - name: Build relay + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} + run: npm run build:relay + + - name: Run relay tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} + run: npm run test --workspace=@getpaseo/relay + + - name: Build client dependencies + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run build:relay && npm run build:client + + - name: Build plugin SDK + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run build:plugin + + - name: Run plugin SDK tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/plugin + + - name: Run protocol tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/protocol + + - name: Run client tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/client + + - name: Run highlight tests + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run test --workspace=@getpaseo/highlight + + - name: Typecheck client examples + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} + run: npm run typecheck:examples --workspace=@getpaseo/client + + # The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04. typecheck: name: typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -114,6 +150,9 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Verify dependency patch recovery + run: node --test scripts/postinstall-patches.test.mjs + - name: Build server stack run: npm run build:server @@ -143,43 +182,56 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: ubuntu-latest - steps: &server_test_steps - - uses: actions/checkout@v4 + steps: + - &server_capacity + name: Log runner capacity + run: | + node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))" + + - &server_checkout + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - &server_node + uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" - - name: Fetch origin/main (worktree tests) + - &server_fetch + name: Fetch origin/main (worktree tests) if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: git fetch --no-tags origin main:refs/remotes/origin/main - - name: Install dependencies + - &server_install + name: Install dependencies run: node scripts/npm-retry.mjs ci - - name: Install agent CLIs for provider tests + - &server_agent_clis + name: Install agent CLIs for provider tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai - - name: Build server dependencies + - &server_build + name: Build server dependencies run: npm run build:server-deps - name: Run server tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/server - env: + env: &server_test_env CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - - name: Run built-in plugin tests + - &server_plugin_tests + name: Run built-in plugin tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/builtin-plugins - - name: Run Hub CLI/daemon contract + - &server_hub_contract + name: Run Hub CLI/daemon contract if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }} run: npm run test:hub-cli-contract --workspace=@getpaseo/server @@ -188,17 +240,51 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: windows-latest - steps: *server_test_steps + steps: + - *server_capacity + - *server_checkout + - *server_node + - *server_fetch + - *server_install + - *server_agent_clis + - *server_build + + - name: Run server unit tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} + run: npm run test:unit --workspace=@getpaseo/server + env: *server_test_env + + - *server_plugin_tests + - *server_hub_contract + + # Keep the serial integration chain off the Windows unit critical path. + server-integration-windows: + name: server-integration (windows-latest) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + runs-on: windows-latest + steps: + - *server_capacity + - *server_checkout + - *server_node + - *server_fetch + - *server_install + - *server_agent_clis + - *server_build + + - name: Run server integration tests + run: npm run test:integration --workspace=@getpaseo/server + env: *server_test_env # native-recursive.ts serves darwin and win32, but windows-latest above is the only # runner that exercises it. macOS — the platform the reported watcher bug was on — # otherwise has no server-test coverage. Scoped to the observer/watcher suites to # bound runtime. server-tests-macos: - name: server-tests (macos-14, file observation) + name: server-tests (macos-latest, file observation) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -310,7 +396,7 @@ jobs: name: app-tests needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -332,52 +418,19 @@ jobs: - name: Run app unit tests run: npm run test --workspace=@getpaseo/app - sdk-tests: - name: sdk-tests - needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - - - name: Install dependencies - run: node scripts/npm-retry.mjs ci - - - name: Build client dependencies - run: npm run build:relay && npm run build:client - - - name: Build plugin SDK - run: npm run build:plugin - - - name: Run plugin SDK tests - run: npm run test --workspace=@getpaseo/plugin - - - name: Run protocol tests - run: npm run test --workspace=@getpaseo/protocol - - - name: Run client tests - run: npm run test --workspace=@getpaseo/client - - - name: Run highlight tests - run: npm run test --workspace=@getpaseo/highlight - - - name: Typecheck client examples - run: npm run typecheck:examples --workspace=@getpaseo/client - playwright-1: - name: playwright (shard 1/4) + name: playwright (shard 1/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: - PLAYWRIGHT_SHARD: "1/4" + PLAYWRIGHT_SHARD: "1/8" PLAYWRIGHT_ARTIFACT: "1" steps: &playwright_test_steps + - name: Log runner capacity + run: | + node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))" + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -405,9 +458,11 @@ jobs: run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - # Weight file groups by observed duration; raw test counts overload - # early agent/Changes specs. Required check names remain unchanged. - PWTEST_SHARD_WEIGHTS: "111:172:137:160" + E2E_WORKERS: "2" + # File groups are weighted by observed reporter durations. + # Share of the test list per shard, rebalanced from the Playwright step + # minutes of five eight-shard runs so the slowest and fastest shards meet. + PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85" - name: Upload test artifacts uses: actions/upload-artifact@v4 @@ -420,56 +475,74 @@ jobs: retention-days: 7 playwright-2: - name: playwright (shard 2/4) + name: playwright (shard 2/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: - PLAYWRIGHT_SHARD: "2/4" + PLAYWRIGHT_SHARD: "2/8" PLAYWRIGHT_ARTIFACT: "2" steps: *playwright_test_steps playwright-3: - name: playwright (shard 3/4) + name: playwright (shard 3/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: - PLAYWRIGHT_SHARD: "3/4" + PLAYWRIGHT_SHARD: "3/8" PLAYWRIGHT_ARTIFACT: "3" steps: *playwright_test_steps playwright-4: - name: playwright (shard 4/4) + name: playwright (shard 4/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: - PLAYWRIGHT_SHARD: "4/4" + PLAYWRIGHT_SHARD: "4/8" PLAYWRIGHT_ARTIFACT: "4" steps: *playwright_test_steps - relay-tests: - name: relay-tests + playwright-5: + name: playwright (shard 5/8) needs: changes - if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-24.04 + env: + PLAYWRIGHT_SHARD: "5/8" + PLAYWRIGHT_ARTIFACT: "5" + steps: *playwright_test_steps - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" + playwright-6: + name: playwright (shard 6/8) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-24.04 + env: + PLAYWRIGHT_SHARD: "6/8" + PLAYWRIGHT_ARTIFACT: "6" + steps: *playwright_test_steps - - name: Install dependencies - run: node scripts/npm-retry.mjs ci + playwright-7: + name: playwright (shard 7/8) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-24.04 + env: + PLAYWRIGHT_SHARD: "7/8" + PLAYWRIGHT_ARTIFACT: "7" + steps: *playwright_test_steps - - name: Build relay - run: npm run build:relay - - - name: Run relay tests - run: npm run test --workspace=@getpaseo/relay + playwright-8: + name: playwright (shard 8/8) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-24.04 + env: + PLAYWRIGHT_SHARD: "8/8" + PLAYWRIGHT_ARTIFACT: "8" + steps: *playwright_test_steps cli-tests-1: name: cli-tests (shard 1/3) diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml index c30eea7da4..77e2735c6a 100644 --- a/.github/workflows/desktop-packages.yml +++ b/.github/workflows/desktop-packages.yml @@ -6,7 +6,6 @@ on: # Packaging leaves ordinary PR CI, but a PR that changes what gets packaged # or how it is smoked still proves the real artifacts before merge. pull_request: - branches: [main] paths: - "packages/desktop/**" - "plugins/**" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6b07c8b37..26be12291b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -100,10 +100,10 @@ jobs: fail-fast: false matrix: include: - - runner: macos-14 + - runner: macos-latest electron_arch: arm64 node_heap_mb: 4096 - - runner: macos-15-intel + - runner: macos-26-intel electron_arch: x64 node_heap_mb: 8192 permissions: @@ -169,6 +169,34 @@ jobs: NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail + export PASEO_REAL_XCRUN="$(command -v xcrun)" + stapler_bin="$(mktemp -d)" + trap 'rm -rf "$stapler_bin"' EXIT + cat > "$stapler_bin/xcrun" <<'STAPLER' + #!/bin/bash + if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then + exec "$PASEO_REAL_XCRUN" "$@" + fi + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + for attempt in 1 2 3 4; do + if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then + cat "$output" + exit 0 + else + status=$? + fi + cat "$output" + if [[ "$status" != 68 || "$attempt" == 4 ]] || + ! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then + exit "$status" + fi + echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2 + sleep 30 + done + STAPLER + chmod +x "$stapler_bin/xcrun" + export PATH="$stapler_bin:$PATH" build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f37a8b0cdf..b653564793 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -101,7 +101,7 @@ jobs: run: nix build .#desktop -o result-desktop build-desktop-darwin: - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/.oxlintrc.json b/.oxlintrc.json index 4582602a97..ae335c0e48 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,6 +5,7 @@ }, "ignorePatterns": [".dev/**"], "plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"], + "jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"], "categories": { "correctness": "error", "suspicious": "error", @@ -84,6 +85,176 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + // Exceptions name individual APIs in individual modules; new APIs still fail in these files. + // TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions. + { + "files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + "rules": { + "paseo/no-sync-io": [ + "error", + { + "allow": { + // TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together. + "executable-resolution/executable-resolution.ts": ["existsSync"], + // TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together. + "executable-resolution/windows.ts": ["readdirSync"], + // TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion. + "server/agent/providers/claude/agent.ts": ["existsSync"], + // TODO: new-session construction still resolves its project directory synchronously; move preparation before construction. + "server/agent/providers/claude/project-dir.ts": ["realpathSync"], + // TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion. + "server/agent/providers/claude/subagents/workflow-output.ts": [ + "closeSync", + "openSync", + "readSync" + ], + // TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness. + "server/agent/providers/omp/agent.ts": ["existsSync"], + // TODO: synchronous provider configuration discovery; move discovery into async launch preparation. + "server/agent/providers/omp/provider-config.ts": ["existsSync"], + // TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness. + "server/agent/providers/omp/session-descriptor.ts": ["existsSync"], + // TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await. + "server/agent/providers/opencode/server-manager.ts": ["mkdirSync"], + // TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together. + "server/agent/providers/pi/agent.ts": [ + "existsSync", + "mkdtempSync", + "readFileSync", + "rmSync", + "writeFileSync" + ], + // Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there. + "server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"], + // Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal. + "server/bootstrap.ts": ["existsSync", "unlinkSync"], + // Startup configuration resolution: detect the bundled app distribution before the daemon listens. + "server/config.ts": ["existsSync"], + // Startup identity: load or create the daemon keypair before accepting connections. + "server/daemon-keypair.ts": ["existsSync", "readFileSync"], + // Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate. + "server/daemon-worker.ts": ["appendFileSync", "mkdirSync"], + // Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache. + "server/host-name.ts": ["execFileSync"], + // TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes. + "server/hub/relationship-controller.ts": [ + "existsSync", + "readFileSync", + "renameSync", + "rmSync" + ], + // Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection. + "server/local-credential.ts": ["readFileSync"], + // Startup logger construction: create the log directory before opening the logging destination. + "server/logger.ts": ["mkdirSync"], + // TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together. + "server/orchestration-skills/internal/paths.ts": ["existsSync"], + // Module initialization: the package version is resolved once and cached at import. + "server/package-version.ts": ["existsSync", "readFileSync"], + // Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities. + "server/persisted-config.ts": ["existsSync", "readFileSync"], + // Startup pid identity: read and memoize the Linux boot id for the daemon lock. + "server/pid-lock.ts": ["readFileSync"], + // Startup builtin loader construction: select the bundled plugin root before loading plugins. + "server/plugins/builtin/index.ts": ["existsSync"], + // Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process. + "server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"], + // Build child process: select the esbuild binary for the isolated plugin compiler. + "server/plugins/compiler-esbuild.ts": ["existsSync"], + // Build child process: TypeScript resolution and source reads run in the isolated plugin compiler. + "server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"], + // TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue. + "server/plugins/managed-source.ts": ["existsSync", "readFileSync"], + // TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports. + "server/private-files.ts": [ + "chmodSync", + "mkdirSync", + "realpathSync", + "renameSync", + "rmSync", + "writeFileSync" + ], + // TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness. + "server/push/token-store.ts": ["existsSync", "readFileSync"], + // Startup identity: load or create the server id before accepting connections. + "server/server-id.ts": ["existsSync", "readFileSync"], + // Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy. + "server/service-proxy.ts": ["existsSync", "unlinkSync"], + // TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together. + "server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"], + // TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection. + "server/session/daemon/npm-global-cli.ts": ["lstatSync"], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"], + // Speech child process: native addon discovery and patchelf run outside the daemon. + "server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [ + "existsSync", + "spawnSync" + ], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"], + // TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion. + "server/web-ui.ts": ["readFileSync", "statSync"], + // Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts. + "server/workspace-registry-bootstrap.ts": ["statSync"], + // Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker. + "terminal/agent-hooks/agent-hook-installer.ts": [ + "existsSync", + "readFileSync", + "rmSync" + ], + // Terminal child process: launcher readiness and PTY record ownership run outside the daemon. + "terminal/terminal-worker-process.ts": [ + "chmodSync", + "closeSync", + "existsSync", + "openSync", + "rmSync", + "writeSync" + ], + // Worker startup only: choose and secure the local listening socket before accepting a controller. + "terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"], + // Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads. + "terminal/terminal-worker-runtime.ts": [ + "chmodSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmSync", + "statSync", + "writeFileSync" + ], + // TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together. + "terminal/paseo-cli-path.ts": ["existsSync"], + // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop. + "terminal/terminal-ts-loader.mjs": ["existsSync"], + // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. + "terminal/terminal.ts": [ + "accessSync", + "chmodSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + // TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them. + "utils/path.ts": ["realpathSync"] + } + } + ] + } + }, + { + "files": [ + "packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "packages/server/src/**/test-utils/**", + "packages/server/src/server/daemon-e2e/**", + "packages/server/src/server/agent/providers/pi/extensions/*-test.ts" + ], + "rules": { "paseo/no-sync-io": "off" } + }, { "files": [ "plugins/*-provider/server/**/*.ts", @@ -235,10 +406,7 @@ { "files": [ "packages/app/src/app/_layout.tsx", - "packages/app/src/app/pair-scan.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx", - "packages/app/src/components/add-host-method-modal.tsx", - "packages/app/src/components/add-host-modal.tsx", "packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/attachment-lightbox.tsx", @@ -257,7 +425,6 @@ "packages/app/src/components/hosts/host-picker.tsx", "packages/app/src/components/icons/paseo-logo.tsx", "packages/app/src/components/left-sidebar.tsx", - "packages/app/src/components/pair-link-modal.tsx", "packages/app/src/components/plan-card.tsx", "packages/app/src/components/provider-diagnostic-sheet.tsx", "packages/app/src/components/question-form-card.tsx", @@ -356,6 +523,113 @@ "no-restricted-imports": "off" } }, + { + "files": ["packages/app/src/**/*.{ts,tsx}"], + "rules": { + "paseo/no-raw-pressable": "error" + } + }, + // Press feedback is owned by the primitives in components/ui. Raw pressable burn-down: + // 94 files. They may only leave this list. + { + "files": [ + "packages/app/src/components/ui/**/*.{ts,tsx}", + "packages/app/src/**/*.{test,spec}.{ts,tsx}", + "packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx", + "packages/app/src/agent-skills/selection-sheet.tsx", + "packages/app/src/agent-stream/chat-outline/rail.web.tsx", + "packages/app/src/agent-stream/view.tsx", + "packages/app/src/changelog/internal/changelog-sheet.tsx", + "packages/app/src/command-center/command-center.tsx", + "packages/app/src/components/adaptive-modal-sheet.tsx", + "packages/app/src/components/add-project-flow.tsx", + "packages/app/src/components/agent-list.tsx", + "packages/app/src/components/app-diagnostic-sheet.tsx", + "packages/app/src/components/attachment-lightbox.tsx", + "packages/app/src/components/attachment-pill.tsx", + "packages/app/src/components/combined-model-selector.tsx", + "packages/app/src/components/compact-explorer-sidebar.tsx", + "packages/app/src/components/context-window-meter.tsx", + "packages/app/src/components/desktop/window-controls.tsx", + "packages/app/src/components/dictation-controls.tsx", + "packages/app/src/components/download-toast.tsx", + "packages/app/src/components/file-explorer-pane.tsx", + "packages/app/src/components/headers/back-header.tsx", + "packages/app/src/components/highlighted-code-block.tsx", + "packages/app/src/components/hosts/host-filter.tsx", + "packages/app/src/components/hosts/host-picker.tsx", + "packages/app/src/components/import-session-sheet.tsx", + "packages/app/src/components/left-sidebar.tsx", + "packages/app/src/components/markdown/fence/mermaid/host.native.tsx", + "packages/app/src/components/markdown/fence/mermaid/host.web.tsx", + "packages/app/src/components/message.tsx", + "packages/app/src/components/model-browser.tsx", + "packages/app/src/components/plan-card.tsx", + "packages/app/src/components/provider-diagnostic-sheet.tsx", + "packages/app/src/components/question-form-card.tsx", + "packages/app/src/components/realtime-voice-overlay.tsx", + "packages/app/src/components/schedules/schedule-row.tsx", + "packages/app/src/components/settings/collapsible-row.tsx", + "packages/app/src/components/settings/headings/settings-info-tip.tsx", + "packages/app/src/components/sidebar-callout.tsx", + "packages/app/src/components/sidebar-resize-handle.tsx", + "packages/app/src/components/sidebar-workspace-list.tsx", + "packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx", + "packages/app/src/components/sidebar/sidebar-header-row.tsx", + "packages/app/src/components/sidebar/sidebar-section-header.tsx", + "packages/app/src/components/sidebar/sidebar-status-list.tsx", + "packages/app/src/components/sidebar/workspace-meta-row/index.tsx", + "packages/app/src/components/terminal-copy-paste-actions.tsx", + "packages/app/src/components/terminal-emulator-native-grid.native.tsx", + "packages/app/src/components/terminal-pane.tsx", + "packages/app/src/components/toast-host.tsx", + "packages/app/src/components/tool-call-sheet.tsx", + "packages/app/src/components/welcome-screen.tsx", + "packages/app/src/components/workspace-hover-card.tsx", + "packages/app/src/components/zoomable-viewport/toolbar.tsx", + "packages/app/src/composer/agent-controls/index.tsx", + "packages/app/src/composer/diff-stat-pill.tsx", + "packages/app/src/composer/dock/internal/background.native.tsx", + "packages/app/src/composer/draft/import-pill.tsx", + "packages/app/src/composer/index.tsx", + "packages/app/src/composer/tracks.tsx", + "packages/app/src/desktop/browser/pane/index.electron.tsx", + "packages/app/src/git/actions-split-button.tsx", + "packages/app/src/git/commits-section/commit-row.tsx", + "packages/app/src/git/commits-section/commits-section.tsx", + "packages/app/src/git/diff-pane.tsx", + "packages/app/src/git/forges/gitlab.view.tsx", + "packages/app/src/git/pull-request-panel/checks-section.tsx", + "packages/app/src/git/pull-request-panel/pane.tsx", + "packages/app/src/git/pull-request-panel/section-kit.tsx", + "packages/app/src/hosts/host-chooser.tsx", + "packages/app/src/mobile-panels/presentation.tsx", + "packages/app/src/panels/new-tab-panel.tsx", + "packages/app/src/panels/setup-panel.tsx", + "packages/app/src/plugins/buttons/view.tsx", + "packages/app/src/review/surface.tsx", + "packages/app/src/screens/open-project-screen.tsx", + "packages/app/src/screens/project-settings-screen.tsx", + "packages/app/src/screens/projects-screen.tsx", + "packages/app/src/screens/settings-screen.tsx", + "packages/app/src/screens/settings/host-appearance-section.tsx", + "packages/app/src/screens/settings/host-page.tsx", + "packages/app/src/screens/settings/providers-section.tsx", + "packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx", + "packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx", + "packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx", + "packages/app/src/screens/workspace/workspace-tab-presentation.tsx", + "packages/app/src/tool-calls/detail-level/overview/sheet.tsx", + "packages/app/src/usage/sidebar-item.tsx", + "packages/app/src/usage/window-bar.tsx", + "packages/app/src/workspace-labels/manager-modal.tsx", + "packages/app/src/workspace-labels/swatch.tsx", + "packages/app/src/workspace/open-in-editor/button.tsx" + ], + "rules": { + "paseo/no-raw-pressable": "off" + } + }, { "files": ["**/e2e/**/fixtures.ts"], "rules": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 80dc5d17e4..6ee9a07b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,255 @@ # Changelog -## 0.12.0-beta.1 - 2026-10-16 +## 0.15.0-beta.1 - 2026-11-01 -Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes. +Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations. + +Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history. + +Worktree setup can copy ignored files and use plugin-defined locations before agents start. + +### Added + +- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371)) +- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger)) +- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena)) +- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc)) +- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348)) +- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim)) +- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso)) +- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177)) +- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181)) + +### Improved + +- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375)) +- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch)) +- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage)) +- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132)) +- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302)) +- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274)) +- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276)) +- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162)) + +### Plugins + +- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet)) +- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger)) +- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc)) + +### Fixed + +- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo)) +- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph)) +- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan)) +- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee)) +- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding)) +- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding)) +- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278)) +- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu)) +- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192)) +- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu)) +- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019)) +- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259)) +- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy)) +- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819)) +- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285)) +- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377)) +- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer)) +- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin)) +- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011)) +- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107)) +- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne)) + +### Notes + +- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them +- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents +- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings + +## 0.14.0 - 2026-10-29 + +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +### Added + +- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938)) +- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) +- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983)) +- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295)) +- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte)) +- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710)) +- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi)) +- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy)) + +### Improved + +- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782)) +- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784)) +- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093)) +- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) +- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart)) +- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928)) +- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901)) +- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945)) +- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) +- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) +- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) + +### Fixed + +- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan)) +- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918)) +- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019)) +- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876)) +- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955)) +- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr)) +- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon)) +- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph)) +- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder)) +- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski)) +- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910)) +- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku)) +- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088)) +- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao)) +- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916)) +- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1)) +- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842)) +- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood)) +- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) + +### Notes + +- Voice mode now defaults to off; enable it in Voice settings +- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings +- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0 +- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) + +## 0.13.2 - 2026-10-26 + +### Fixed + +- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### Fixed + +- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt)) + +## 0.13.0 - 2026-10-24 + +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +```sh +paseo host add 192.168.1.20:6767 --label studio +paseo host add 'ssh://me@build-box' --label build-box +paseo host add 'relay://...' --label laptop +``` + +### Added + +- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705)) +- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) +- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576)) +- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453)) +- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro)) + +### Improved + +- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771)) +- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690)) +- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611)) +- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580)) +- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck)) +- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639)) +- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) +- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561)) +- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) +- Paused status ring animations inside hidden panels on iOS and Android ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen)) +- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### Plugins + +- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614)) +- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685)) +- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478)) +- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579)) +- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368)) +- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger)) +- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) +- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630)) +- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream)) +- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) + +### Fixed + +- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683)) +- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng)) +- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej)) +- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja)) +- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638)) +- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681)) +- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) +- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024)) +- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737)) +- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian)) +- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia)) +- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768)) +- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688)) +- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) +- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547)) +- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538)) +- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) +- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701)) +- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362)) +- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) +- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365)) +- Fixed `paseo agent archive` reporting `AGENT_NOT_FOUND` for agents beyond the first 200 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337)) +- Fixed archived agents reappearing in MCP `list_agents` after their history was read ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan)) +- Fixed detached agents created with `notifyOnFinish` never notifying their creator ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan)) +- Fixed OpenCode agents being told `notifyOnFinish` defaults to off ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep)) +- Fixed Pi subagent rows staying running when a resume is refused ([#7369](https://github.com/getpaseo/paseo/pull/7369)) +- Fixed Android source builds downloading a second NDK ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang)) +- Fixed Nix packages reporting the AGPL license instead of Apache-2.0 ([#7415](https://github.com/getpaseo/paseo/pull/7415)) + +## 0.12.0 - 2026-10-19 + +Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes. **Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds @@ -27,8 +274,15 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958)) - Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455)) - Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129)) -- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277)) +- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290)) +- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364)) +- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283)) +- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239)) +- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342)) - Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157)) +- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242)) +- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells)) - Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam)) ### Plugins @@ -37,6 +291,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR)) - Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD)) - Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph)) +- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel)) +- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri)) +- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso)) +- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday)) ### Fixed @@ -50,16 +308,29 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695)) - Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo)) - Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308)) +- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352)) +- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265)) +- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8)) - Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379)) +- Fixed new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256)) +- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss)) - Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi)) - Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet)) - Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka)) +- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth)) - Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone)) - Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger)) - Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons)) - Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330)) - Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519)) +- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183)) - Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325)) +- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179)) +- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1)) +- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266)) +- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234)) - Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr)) - Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan)) - Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen)) @@ -80,15 +351,24 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542)) - Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa)) - Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC)) -- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) +- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33)) +- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) +- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman)) +- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353)) - Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin)) +- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315)) +- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336)) - Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip)) - Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877)) - Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229)) +- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233)) - Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja)) - Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph)) - Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150)) -- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82)) +- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167)) +- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200)) +- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82)) - Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher)) - Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd)) - Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755)) @@ -108,6 +388,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741)) - Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi)) - Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106)) +- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung)) - Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros)) - Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj)) - Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507)) @@ -132,7 +413,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339)) - Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter)) - Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq)) +- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246)) +- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344)) - Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy)) +- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber)) +- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava)) ## 0.11.4 - 2026-10-15 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md new file mode 100644 index 0000000000..532ed24887 --- /dev/null +++ b/CHANGELOG.zh-CN.md @@ -0,0 +1,3485 @@ +# Changelog + +## 0.15.0-beta.1 - 2026-11-01 + +daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。 + +移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。 + +worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。 + +### 新增 + +- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371)) +- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger)) +- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena)) +- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc)) +- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348)) +- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim)) +- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso)) +- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177)) +- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181)) + +### 改进 + +- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375)) +- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch)) +- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage)) +- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132)) +- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302)) +- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274)) +- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276)) +- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162)) + +### 插件 + +- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet)) +- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger)) +- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc)) + +### 修复 + +- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo)) +- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph)) +- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan)) +- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee)) +- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang)) +- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding)) +- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding)) +- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278)) +- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu)) +- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192)) +- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu)) +- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019)) +- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259)) +- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy)) +- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819)) +- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285)) +- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377)) +- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer)) +- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin)) +- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011)) +- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107)) +- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne)) + +### 说明 + +- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端 +- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能 +- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置 + +## 0.14.0 - 2026-10-29 + +插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 + +已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。 + +语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。 + +### 新增 + +- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938)) +- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) +- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983)) +- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295)) +- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte)) +- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710)) +- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi)) +- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy)) + +### 改进 + +- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782)) +- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) +- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093)) +- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) +- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart)) +- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928)) +- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901)) +- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945)) +- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) +- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) +- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) + +### 修复 + +- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan)) +- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918)) +- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds)) +- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019)) +- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876)) +- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955)) +- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr)) +- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon)) +- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph)) +- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder)) +- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski)) +- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910)) +- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku)) +- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088)) +- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao)) +- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916)) +- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1)) +- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842)) +- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood)) +- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) + +### 说明 + +- 语音模式默认关闭,可在语音设置中启用 +- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置 +- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置 +- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) + +## 0.13.2 - 2026-10-26 + +### 修复 + +- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### 修复 + +- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献) + +## 0.13.0 - 2026-10-24 + +折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 + +将你的 daemon 连接到另一台机器,然后让 Agent 把工作委派过去: + +```sh +paseo host add 192.168.1.20:6767 --label studio +paseo host add 'ssh://me@build-box' --label build-box +paseo host add 'relay://...' --label laptop +``` + +### 新增 + +- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705)) +- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) +- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576)) +- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453)) +- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro)) + +### 改进 + +- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771)) +- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690)) +- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611)) +- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580)) +- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck)) +- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639)) +- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) +- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561)) +- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) +- iOS 和 Android 上隐藏面板中的状态环动画现在会暂停 ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen)) +- 应用启动时只加载所选语言的翻译,减少了启动工作量 ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 工作区布局未变化时不再重复保存 ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### 插件 + +- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614)) +- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685)) +- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478)) +- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579)) +- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368)) +- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger)) +- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) +- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630)) +- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream)) +- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) + +### 修复 + +- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683)) +- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng)) +- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej)) +- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja)) +- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638)) +- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681)) +- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) +- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024)) +- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737)) +- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian)) +- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia)) +- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768)) +- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688)) +- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) +- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547)) +- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538)) +- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) +- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701)) +- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362)) +- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) +- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365)) +- 修复了 `paseo agent archive` 对前 200 个之后的 Agent 报告 `AGENT_NOT_FOUND` 的问题 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337)) +- 修复了读取历史记录后已归档的 Agent 重新出现在 MCP `list_agents` 中的问题 ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan)) +- 修复了使用 `notifyOnFinish` 创建的分离 Agent 从不通知其创建者的问题 ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan)) +- 修复了 OpenCode Agent 被告知 `notifyOnFinish` 默认关闭的问题 ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep)) +- 修复了恢复被拒绝时 Pi 子 Agent 行仍显示为运行中的问题 ([#7369](https://github.com/getpaseo/paseo/pull/7369)) +- 修复了 Android 源码构建下载第二个 NDK 的问题 ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang)) +- 修复了 Nix 包将许可证报告为 AGPL 而不是 Apache-2.0 的问题 ([#7415](https://github.com/getpaseo/paseo/pull/7415)) + +## 0.12.0 - 2026-10-19 + +后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及超过 120 项修复。 + +**升级前须知**:`paseo run --background` 已弃用,请改用 `--no-wait`;当 daemon 在 15 秒内未确认时,`paseo permit allow` 和 `deny` 现在会以非零状态退出 + +### 新增 + +- 新增后台工作区,除非开启 Show background,否则不会显示在侧边栏中 ([#5039](https://github.com/getpaseo/paseo/pull/5039), [#7141](https://github.com/getpaseo/paseo/pull/7141) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio)) +- 在 diff 窗格中新增 Send feedback 按钮,无需打开 Agent 的聊天即可把审查评论发送给它 ([#6455](https://github.com/getpaseo/paseo/pull/6455)) +- 在手机上新增全屏 Composer ([#6531](https://github.com/getpaseo/paseo/pull/6531), [#6902](https://github.com/getpaseo/paseo/pull/6902), [#6945](https://github.com/getpaseo/paseo/pull/6945), [#6987](https://github.com/getpaseo/paseo/pull/6987), [#7130](https://github.com/getpaseo/paseo/pull/7130), [#7170](https://github.com/getpaseo/paseo/pull/7170)) +- 一个 Agent 发给另一个 Agent 的消息现在会显示发送者的名称 ([#6373](https://github.com/getpaseo/paseo/pull/6373)) +- 当已安装的 Claude Code 提供 Claude Mythos 5.1 时新增该模型 ([#6403](https://github.com/getpaseo/paseo/pull/6403)) +- Claude 问题卡片新增选项预览 ([#6171](https://github.com/getpaseo/paseo/pull/6171) by [@gabrielgiordan](https://github.com/gabrielgiordan)) +- 新增 `paseo usage`,可在 CLI 中查看账号配额、余额和重置时间 ([#6474](https://github.com/getpaseo/paseo/pull/6474)) + +### 改进 + +- iOS 和 Android 上回复中的文本选择现在可以跨越段落、标题和列表 ([#6973](https://github.com/getpaseo/paseo/pull/6973)) +- iOS 和 Android 上的代码块新增复制按钮 ([#6973](https://github.com/getpaseo/paseo/pull/6973)) +- iOS 和 Android 上较宽的 Markdown 表格现在可以横向滚动 ([#6973](https://github.com/getpaseo/paseo/pull/6973)) +- 手机布局改为浮动的标题栏 ([#6431](https://github.com/getpaseo/paseo/pull/6431)) +- 手机上的 New agent 和 New terminal 移到了标签页切换器中 ([#6431](https://github.com/getpaseo/paseo/pull/6431)) +- 手机上的工作区脚本移到了 ⋯ → Scripts 中,并显示正在运行的内容 ([#7122](https://github.com/getpaseo/paseo/pull/7122)) +- 手机上的文件和子 Agent 改为在可关闭的底部面板中打开,子 Agent 还可以 Open as tab ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958)) +- 手机上的 diff 评论改为在底部面板中打开,而不是在代码中嵌入编辑器 ([#6455](https://github.com/getpaseo/paseo/pull/6455)) +- 手机上的 Settings 改为大图块,并分为 App 和 Host 两组 ([#7129](https://github.com/getpaseo/paseo/pull/7129)) +- Command Center 现在按匹配度和最近使用对工作区和 Agent 排序,并显示时间戳、项目图标和高亮的匹配内容 ([#7277](https://github.com/getpaseo/paseo/pull/7277)) +- 子 Agent 列表中每个 Paseo 子 Agent 的标题下方新增其模型、思考、模式和上下文 ([#7290](https://github.com/getpaseo/paseo/pull/7290)) +- 手机上 Subagents 面板中的子 Agent 行更高了,其归档和分离按钮也使用了标准的按压高亮 ([#7364](https://github.com/getpaseo/paseo/pull/7364)) +- 聊天中的 Paseo 工具调用现在以操作及其上下文开头,例如 Send message · 提示词的第一行 ([#7283](https://github.com/getpaseo/paseo/pull/7283)) +- 手机上的 New workspace 表单改为全宽字段,Import session 移到了标题栏 ([#7239](https://github.com/getpaseo/paseo/pull/7239)) +- 减少了回复流式输出时长聊天的渲染工作量 ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342)) +- 重新打开侧边栏的速度提升约 45% ([#7157](https://github.com/getpaseo/paseo/pull/7157)) +- 应用重新连接后,项目和工作区加载更快 ([#7242](https://github.com/getpaseo/paseo/pull/7242)) +- 一个 Agent 通过 CLI 或 MCP 发给另一个 Agent 的消息现在会引导其正在进行的回合,而不是中断它 ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells)) +- Gitea 和 Forgejo 的 fork PR 检出现在会在分支名中包含 fork 所有者,例如 `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam)) + +### 插件 + +- 修复了导入 Paseo 协议类型的插件安装失败并提示 "Could not resolve type dependency" 的问题 ([#7023](https://github.com/getpaseo/paseo/pull/7023)) +- 修复了移动应用上的插件中 `providers.waitForReady` 抛出错误的问题 ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR)) +- 修复了聊天大纲无法跳转到由时间线插件渲染的提示词的问题 ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD)) +- 修复了按 Escape 会停止正在运行的 Agent,而不是先关闭打开的插件预览的问题 ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph)) +- 修复了 `paseo plugin reload` 在重新加载仍在进行时因 60 秒超时而失败的问题 ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel)) +- 修复了插件 ACP Agent 运行期间发送的后续消息失败并提示 "Provider does not support prompt.steer" 的问题 ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri)) +- 修复了 Find 在时间线插件绘制的提示词文本中找不到匹配的问题 ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso)) +- 修复了重新打开聊天后,延迟加载的 Composer 插件胶囊遮挡 Copy 和 Fork 的问题 ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday)) + +### 修复 + +- 修复了 iOS 回复中的列表项渲染为空白,以及调整大小或旋转后文本保持旧宽度的问题 ([#6973](https://github.com/getpaseo/paseo/pull/6973)) +- 修复了插件宿主、CLI 和桌面应用运行 30 到 75 分钟后内存增长到约 1 GB 的问题 ([#6847](https://github.com/getpaseo/paseo/pull/6847) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了 OpenCode v2 长时间的回复或推理流导致 daemon 内存耗尽的问题 ([#6262](https://github.com/getpaseo/paseo/pull/6262)) +- 修复了已归档的 ACP Agent 每次加载时间线都会启动一个持续运行的 Provider 进程的问题 ([#6766](https://github.com/getpaseo/paseo/pull/6766) by [@apple-ouyang](https://github.com/apple-ouyang)) +- 修复了过大的附件导致主机循环断开连接,而不是报告大小错误的问题 ([#6285](https://github.com/getpaseo/paseo/pull/6285)) +- 修复了通过中继、SSH 或 socket 连接的主机上 Files 中的 Download 失败的问题 ([#6595](https://github.com/getpaseo/paseo/pull/6595) by [@odoo-mvds](https://github.com/odoo-mvds), [@YASICJUNWOO](https://github.com/YASICJUNWOO), [@JrDw0](https://github.com/JrDw0)) +- 修复了使用自定义 Git diff 设置(例如 `color.diff=always` 或外部 diff 命令)时 Changes 不显示 diff 行的问题 ([#6536](https://github.com/getpaseo/paseo/pull/6536), [#6562](https://github.com/getpaseo/paseo/pull/6562) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了在 Appearance 中输入不完整的字体名称会破坏应用样式的问题 ([#6695](https://github.com/getpaseo/paseo/pull/6695)) +- 修复了隐藏窗口没有渲染任何帧时 Linux 桌面窗口始终不出现的问题 ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo)) +- 修复了同一主机上另一个 Agent 正在流式输出时,打开其他 Agent 或工作区会卡住的问题 ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了项目的新建工作区按钮选中第一个主机,而不是当前工作区所在主机的问题 ([#7308](https://github.com/getpaseo/paseo/pull/7308)) +- 修复了在触屏浏览器中用键盘快捷键切换标签页后,Composer 仍保持聚焦的问题 ([#7352](https://github.com/getpaseo/paseo/pull/7352)) +- 修复了在大型目录树上同时进行多个目录搜索时 daemon 内存耗尽的问题 ([#7265](https://github.com/getpaseo/paseo/pull/7265)) +- 修复了打开多个窗格时拖动 Explorer 边缘卡顿的问题 ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了 Provider 拒绝第一条提示词时,创建 Agent 显示错误和重试草稿的问题 ([#6379](https://github.com/getpaseo/paseo/pull/6379)) +- 修复了主机断开时新 Agent 草稿一直停留在 "Loading…" 的问题;现在会显示 "Host is not connected" 并保留你的草稿 ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了在流式回复期间重新加载聊天会在实时回复上方显示一份冻结的副本并丢失结尾的问题 ([#7256](https://github.com/getpaseo/paseo/pull/7256)) +- 修复了答案发送失败时问题卡片一直转圈的问题;现在会显示错误并保留你的答案 ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss)) +- 修复了子 Agent 很快完成第一个回合时父 Agent 收不到完成通知的问题 ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi)) +- 修复了子 Agent 的任务被停止或重新加载时父 Agent 被告知子 Agent 已完成的问题 ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了包含 U+2028 或 U+2029 的 Codex 聊天在 Node 24 上无法重新打开的问题 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka)) +- 修复了恢复的 Codex 目标在继续执行后显示为空闲、且无法停止的问题 ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth)) +- 修复了 macOS 上 Add Project 的目录搜索进入 `~/Library` 后导致 daemon 挂起的问题 ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone)) +- 修复了保存的模式不被 Provider 提供时 New workspace 失败并提示 "Invalid mode" 的问题 ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger)) +- 修复了回答 OpenCode v2 的多选问题失败并提示 "Invalid answer" 的问题 ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons)) +- 修复了 OpenCode v2 Agent 上浏览器截图导致回合失败的问题 ([#6330](https://github.com/getpaseo/paseo/pull/6330)) +- 修复了 OpenCode v2 Agent 收到 "HTTP 500" 而不是 Paseo 工具错误的问题 ([#6519](https://github.com/getpaseo/paseo/pull/6519)) +- 修复了 OpenCode Agent 使用无效参数或未知名称调用 Paseo 工具时收到 "HTTP 500" 而不是可读错误的问题 ([#7183](https://github.com/getpaseo/paseo/pull/7183)) +- 修复了 Provider 设置了 `CLAUDE_CONFIG_DIR` 时回退 Claude 对话失败并提示 "Session not found" 的问题 ([#6325](https://github.com/getpaseo/paseo/pull/6325)) +- 修复了先修改正在运行的 Claude Agent 的思考级别、再修改其模式会终止其正在运行的命令的问题 ([#7179](https://github.com/getpaseo/paseo/pull/7179)) +- 修复了 Provider 命令是包装脚本时需要较新 Claude Code 的 Claude 模型被隐藏的问题 ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1)) +- 修复了后台 Claude 子 Agent 卡片在子 Agent 工作期间(包括父 Agent 完成之后)一直为空的问题 ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266)) +- 修复了选择 Allow All 之后 Copilot 仍停留在 Plan 模式并拒绝编辑的问题 ([#7234](https://github.com/getpaseo/paseo/pull/7234)) +- 修复了导入的 Claude Code 会话以第一条提示词而不是 `/rename` 标题作为名称的问题 ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr)) +- 修复了对 Claude 问题给出格式错误的答案时报告成功并丢弃该问题的问题 ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan)) +- 修复了 Cursor Agent 的上下文详情中不显示套餐用量的问题 ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen)) +- 修复了代理在响应结束时报告输入用量时,Claude 上下文计量只统计输出 token 的问题 ([#6703](https://github.com/getpaseo/paseo/pull/6703) by [@kywoo26](https://github.com/kywoo26)) +- 修复了切换回 200k 模型后 Claude 上下文计量仍显示 1M 上限的问题 ([#6814](https://github.com/getpaseo/paseo/pull/6814)) +- 修复了自定义 Claude `[1m]` 模型在第一个回合期间上下文计量一直为空的问题 ([#6767](https://github.com/getpaseo/paseo/pull/6767)) +- 修复了定时任务的某次运行无法记录后该定时任务再也不运行的问题 ([#6334](https://github.com/getpaseo/paseo/pull/6334)) +- 修复了共享同一 cron 分钟的定时任务会把一个时段运行两次并跳过下一次的问题 ([#6532](https://github.com/getpaseo/paseo/pull/6532) by [@keithpk](https://github.com/keithpk)) +- 修复了从已有分支创建的 worktree 在 Committed 中不显示更改的问题 ([#6462](https://github.com/getpaseo/paseo/pull/6462) by [@cr-2takumax](https://github.com/cr-2takumax)) +- 修复了在仓库子目录打开的工作区中 Changes 的 Discard 和提交 diff 的问题 ([#6777](https://github.com/getpaseo/paseo/pull/6777), [#6801](https://github.com/getpaseo/paseo/pull/6801), [#6882](https://github.com/getpaseo/paseo/pull/6882) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了重命名并编辑过的文件在 Changes 中每一行都显示为新增的问题 ([#6559](https://github.com/getpaseo/paseo/pull/6559)) +- 修复了从某个提交打开的重命名文件显示为新文件的问题 ([#6895](https://github.com/getpaseo/paseo/pull/6895)) +- 修复了文件名包含引号、反斜杠或制表符时 Changes 不显示 diff 的问题 ([#6583](https://github.com/getpaseo/paseo/pull/6583) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了 Changes 丢失以 `--` 开头的删除行(例如 SQL 注释)的问题 ([#6569](https://github.com/getpaseo/paseo/pull/6569) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了干净的工作区在第一次编辑后 Diff 标签页一直转圈的问题 ([#6745](https://github.com/getpaseo/paseo/pull/6745)) +- 修复了在添加时还不是 Git 仓库的项目中运行 `git init` 后创建 worktree 失败的问题 ([#6785](https://github.com/getpaseo/paseo/pull/6785)) +- 修复了新 worktree 在设置完成后不打开 `paseo.json` 中配置的终端的问题 ([#6999](https://github.com/getpaseo/paseo/pull/6999) by [@davidyang](https://github.com/davidyang)) +- 修复了新添加到 `paseo.json` 的服务在 daemon 重启之前无法启动的问题 ([#6542](https://github.com/getpaseo/paseo/pull/6542)) +- 修复了服务只监听 IPv6 localhost 时工作区服务 URL 返回 502 的问题 ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa)) +- 修复了命令缺失或立即退出时终端无法关闭的问题 ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC)) +- 修复了 MCP 终端按键命令输入方向键和修饰键的名称,而不是发送这些按键的问题 ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33)) +- 修复了返回工作区后,原生终端中 CJK 文本的字符之间多出空格的问题 ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) +- 修复了在 iOS 和 Android 上从终端复制韩文文本时字符之间多出空格或丢失第一个字符的问题 ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman)) +- 修复了在 iOS 和 Android 上长按终端中的韩文单词时只选中一个字符、而不是整个单词的问题 ([#7353](https://github.com/getpaseo/paseo/pull/7353)) +- 修复了自托管的 Web 应用通过 80 或 443 端口上的反向代理连接失败的问题 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin)) +- 修复了网络静默故障后 Hub 连接再也不会重连的问题 ([#7315](https://github.com/getpaseo/paseo/pull/7315)) +- 修复了另一个解析出的地址不可达时,远程连接在慢速网络上失败的问题 ([#7336](https://github.com/getpaseo/paseo/pull/7336)) +- 修复了助手回复中包含空格或非 ASCII 字符的文件链接以 ENOENT 失败的问题 ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip)) +- 修复了 Agent 回复中路径包含空格或非 ASCII 字符的本地图片无法加载的问题 ([#6877](https://github.com/getpaseo/paseo/pull/6877)) +- 修复了 Markdown 文件预览不显示通过相对路径引用的图片的问题 ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang)) +- 修复了聊天和 Markdown 预览中的文件夹链接打开错误标签页或没有反应的问题;现在会打开 Files 并显示该文件夹的内容 ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229)) +- 修复了指向某些类型文件的 Markdown 链接(例如 `[Capture](capture.png)`)没有反应的问题 ([#7233](https://github.com/getpaseo/paseo/pull/7233)) +- 修复了 iOS 侧边栏行在短暂按住后保持高亮并吞掉下一次点击的问题 ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja)) +- 修复了 Android 上长按选择文本时聊天会滚动的问题 ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph)) +- 修复了在触摸设备上点击侧边栏行、底部按钮和 Settings 项时没有反馈的问题 ([#7150](https://github.com/getpaseo/paseo/pull/7150)) +- 修复了较矮的手机屏幕上主屏幕操作被截断的问题 ([#7167](https://github.com/getpaseo/paseo/pull/7167)) +- 修复了其他应用播放音频时 Android 语音输入停止并提示 "Dictation was interrupted by another audio source" 的问题 ([#7200](https://github.com/getpaseo/paseo/pull/7200)) +- 修复了点击消息或关闭 Find 之后 Page Up 和 Page Down 无法滚动聊天的问题 ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82)) +- 修复了 Add Project 的键盘选择移动到滚出视图的目录的问题 ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher)) +- 修复了按 Enter 会把 Composer 中的电子邮件地址替换为文件提及的问题 ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd)) +- 修复了使用俄语等非拉丁键盘布局时字母快捷键不生效的问题 ([#6755](https://github.com/getpaseo/paseo/pull/6755)) +- 修复了在 Dvorak 等布局上录制快捷键时保存的是 QWERTY 键而不是输入的字母的问题 ([#6652](https://github.com/getpaseo/paseo/pull/6652)) +- 修复了重新绑定 Jump to tab 或 Jump to workspace 时只保留按下的数字而不是 1–9 的问题;请重新录制已有的覆盖设置 ([#7065](https://github.com/getpaseo/paseo/pull/7065) by [@dbunk903](https://github.com/dbunk903)) +- 修复了 Previous 和 Next workspace 快捷键在第 9 个工作区处停止的问题 ([#6586](https://github.com/getpaseo/paseo/pull/6586) by [@IEatCodeDaily](https://github.com/IEatCodeDaily)) +- 修复了 Light 主题下以及系统主题变化后 Android 状态栏图标难以辨认的问题 ([#6707](https://github.com/getpaseo/paseo/pull/6707) by [@terayang](https://github.com/terayang)) +- 修复了 iOS 和 Android 上配对二维码被裁切的问题 ([#6758](https://github.com/getpaseo/paseo/pull/6758) by [@fdkevin0](https://github.com/fdkevin0)) +- 修复了启动器停止读取控制台输出时 Linux 桌面端出现 `write EPIPE` 错误对话框的问题 ([#6639](https://github.com/getpaseo/paseo/pull/6639)) +- 修复了通过 SSH 执行 `paseo permit allow` 和 `deny` 时报告成功但请求仍处于待处理状态的问题 ([#6607](https://github.com/getpaseo/paseo/pull/6607) by [@budivoogt](https://github.com/budivoogt)) +- 修复了名称以两个点开头的文件和文件夹(例如 `..notes.txt`)被隐藏和拒绝的问题 ([#6604](https://github.com/getpaseo/paseo/pull/6604), [#6720](https://github.com/getpaseo/paseo/pull/6720) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了 Markdown 预览在非 HTML 的 `<`(例如 `List`)之后丢失表格行的问题 ([#6370](https://github.com/getpaseo/paseo/pull/6370) by [@gengjiawen](https://github.com/gengjiawen)) +- 修复了 HTML 标题包含带链接的图片时显示字面标题标签的问题 ([#6650](https://github.com/getpaseo/paseo/pull/6650)) +- 修复了包含空 `{}` 表达式的 Astro diff 无法渲染的问题 ([#6763](https://github.com/getpaseo/paseo/pull/6763)) +- 修复了 ACP Provider 的自动补全中缺少斜杠命令参数提示的问题 ([#6920](https://github.com/getpaseo/paseo/pull/6920) by [@bwestlund17](https://github.com/bwestlund17)) +- 修复了配置为 `agy` 图标的终端配置显示通用机器人图标而不是 Antigravity 图标的问题 ([#6938](https://github.com/getpaseo/paseo/pull/6938) by [@noahg9](https://github.com/noahg9)) +- 修复了 Pi 扩展修改 Pi 思考级别后该级别没有更新的问题 ([#6741](https://github.com/getpaseo/paseo/pull/6741)) +- 修复了来自 `@tintinweb/pi-subagents` 的 Pi 子 Agent 在达到回合上限后仍保持运行的问题 ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi)) +- 修复了在 OMP Agent 上手动执行 `/compact` 时不显示进度并且上下文计量不更新的问题 ([#6106](https://github.com/getpaseo/paseo/pull/6106)) +- 修复了发给空闲 OMP Agent 的后续消息一直排队而不是启动该 Agent 的问题 ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung)) +- 修复了语音模式的 TTS 拒绝来自 OpenAI 兼容服务器的模型和声音名称的问题 ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros)) +- 修复了 xhigh 思考选项显示 "Extra high" 而不是其配置的标签的问题 ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj)) +- 修复了 Provider 设置中自定义模型名称较长时 Remove 按钮超出屏幕的问题 ([#6507](https://github.com/getpaseo/paseo/pull/6507)) +- 修复了起始 ref 选择器无法按编号找到 GitLab 合并请求和 issue 的问题 ([#6525](https://github.com/getpaseo/paseo/pull/6525)) +- 修复了起始 ref 选择器隐藏只在描述中匹配的变更请求的问题 ([#6558](https://github.com/getpaseo/paseo/pull/6558)) +- 修复了 @ 提及找不到 `.cursor/plans` 下的计划文件的问题 ([#6641](https://github.com/getpaseo/paseo/pull/6641) by [@chyendongnhanh338](https://github.com/chyendongnhanh338)) +- 修复了保存设置时 Paseo 把作为符号链接的 `config.json` 替换为普通文件的问题 ([#6023](https://github.com/getpaseo/paseo/pull/6023)) +- 修复了发布的配置 schema 拒绝 `agentProfiles` 和 `plugins` 等有效设置的问题 ([#6621](https://github.com/getpaseo/paseo/pull/6621) by [@ragokan](https://github.com/ragokan)) +- 修复了复制文件夹会把其中的相对符号链接变成指向原始位置的绝对链接的问题 ([#6771](https://github.com/getpaseo/paseo/pull/6771) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了当某个已跟踪文件把未跟踪文件(例如 `[id].tsx`)当作模式匹配时,重命名该文件失败的问题 ([#6573](https://github.com/getpaseo/paseo/pull/6573) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了保存名称较长的工作区文件时以 `ENAMETOOLONG` 失败的问题 ([#6658](https://github.com/getpaseo/paseo/pull/6658) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了文件夹名称以空格结尾的项目创建 worktree 和使用 Changes 失败的问题 ([#6686](https://github.com/getpaseo/paseo/pull/6686)) +- 修复了基础分支的 worktree 路径包含换行符或以空格结尾时合并 worktree 的问题 ([#6668](https://github.com/getpaseo/paseo/pull/6668) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了 `paseo agent ls` 只列出前 200 个 Agent 的问题 ([#6033](https://github.com/getpaseo/paseo/pull/6033) by [@hteo1337](https://github.com/hteo1337)) +- 修复了 `paseo worktree ls` 和 `paseo worktree archive` 失败并提示 "cwd or repoRoot is required" 的问题 ([#6467](https://github.com/getpaseo/paseo/pull/6467) by [@jasonhnd](https://github.com/jasonhnd)) +- 修复了 `paseo worktree ls` 在最多 15 秒内仍列出已归档 worktree 的问题 ([#6545](https://github.com/getpaseo/paseo/pull/6545) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了 `paseo logs --since` 输出请求时间戳之前的活动的问题 ([#6514](https://github.com/getpaseo/paseo/pull/6514) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了 `paseo logs --follow` 在文本流式输出时把单词拆到多行的问题 ([#6528](https://github.com/getpaseo/paseo/pull/6528) by [@fiorelorenzo](https://github.com/fiorelorenzo)) +- 修复了 `paseo logs` 把推理内容输出在其后的回复之后的问题 ([#6546](https://github.com/getpaseo/paseo/pull/6546)) +- 修复了取消 HTTP 下载后 daemon 仍保持文件打开的问题 ([#6567](https://github.com/getpaseo/paseo/pull/6567) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了 Linux 桌面窗口显示通用图标而不是 Paseo 图标的问题 ([#6645](https://github.com/getpaseo/paseo/pull/6645) by [@whme](https://github.com/whme), [@panta82](https://github.com/panta82)) +- 修复了 Google Play 把没有摄像头的 Android 设备列为不兼容的问题 ([#6339](https://github.com/getpaseo/paseo/pull/6339)) +- 修复了在内存少于 15 GB 的 Mac 上 `nix build .#desktop` 内存耗尽的问题 ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter)) +- 修复了 Refresh all 之后用量 Provider 的位置互换的问题 ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq)) +- 修复了刷新失败时用量卡片丢失最近的值并显示 "Updated" 的问题 ([#7246](https://github.com/getpaseo/paseo/pull/7246)) +- 修复了用量把不同的 Cursor 账号合并到同一张卡片中的问题 ([#7344](https://github.com/getpaseo/paseo/pull/7344)) +- 修复了 OpenCode Go 用量图标在深色主题上几乎看不见的问题 ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy)) +- 修复了 Provider 列表中 Devin、Hermes、Kiro 和 Antigravity 显示通用图标的问题 ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber)) +- 修复了翻译后的标签较长时,侧边栏中的设置按钮溢出卡片的问题 ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava)) + +## 0.11.4 - 2026-10-15 + +[Hoplite](https://hoplite.sh) 通过企业赞助计划成为 Paseo 的第一家赞助商。Hoplite 构建云端编码 Agent,可以把来自 GitHub、Linear、Slack 等的任务变成拉取请求。感谢 Hoplite 团队对 Paseo 的支持。 + +### 修复 + +- 修复了在关联到 Hoplite 项目的工作区中,Hoplite 在 Settings → Providers 中显示错误而不是发现模型的问题 + +## 0.11.3 - 2026-10-15 + +### 新增 + +- 在 ACP Provider 目录中新增 Hoplite,作为可选启用的 Provider ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0)) +- Add provider 目录新增搜索 ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030)) + +### 变更 + +- Add provider 失败现在以 toast 显示 ([#7030](https://github.com/getpaseo/paseo/pull/7030)) + +### 修复 + +- 修复了 Antigravity 和 Muse Code 等插件 Provider 也提供 Remove 的问题 ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper)) + +## 0.11.2 - 2026-10-09 + +### 修复 + +- 修复了 iOS 聊天和 Markdown 预览中文本间歇性被裁切的问题 ([#6445](https://github.com/getpaseo/paseo/pull/6445)) + +## 0.11.1 - 2026-10-07 + +### 新增 + +- 为 Claude Code 2.1.293 及更新版本新增 Claude Haiku 5.5 ([#6332](https://github.com/getpaseo/paseo/pull/6332)) + +### 修复 + +- 修复了运行超过 30 秒的 Claude 工具调用(例如 WebFetch 或 MCP 工具)显示为子 Agent 的问题 ([#6308](https://github.com/getpaseo/paseo/pull/6308)) +- 修复了在应用断开期间完成的子 Agent 一直显示 "working",直到打开其中一个为止的问题 ([#6316](https://github.com/getpaseo/paseo/pull/6316)) +- 修复了 Claude Agent 的对话记录位于另一个项目文件夹时(例如迁移了 daemon 或从 WSL 运行 Windows 版 Claude Code 之后),daemon 重启后其历史记录显示 "No activity to display" 的问题 ([#6301](https://github.com/getpaseo/paseo/pull/6301)) + +## 0.11.0 - 2026-10-07 + +Paseo 0.11 新增对[插件注册表](https://paseo.sh/plugins)的支持。你可以在那里浏览社区插件,用 `paseo plugin add owner/slug` 安装插件,或者[提交你自己的插件](https://paseo.sh/docs/plugins/publishing#plugin-registry)。 + +**升级前须知**:`paseo plugin add owner/repo` 现在从注册表安装,因此直接从 GitHub 安装请使用 `github:owner/repo`,安装本地目录请使用 `./path`。 + +### 新增 + +- 新增 Muse Code Provider,支持模型、审批模式和推理强度控制 ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) +- 新增 Antigravity Provider,它驱动已安装的 `agy` CLI,并始终使用 Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) +- 新增 Usage,从侧边栏底部打开(桌面端为对话框,移动端为底部面板),显示每个订阅账号的配额窗口并支持 Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216)) +- 在侧边栏底部新增可选启用的 Usage 摘要,显示你固定的窗口 ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Usage 新增从 Codex CLI、OpenCode、Pi 和 OMP 发现 ChatGPT 登录 ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Usage 新增从 Claude Code 凭据文件、macOS 钥匙串、Pi 和 OMP 发现 Claude 登录 ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Usage 新增 OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- 新增 Usage 卡片不可用的原因,例如登录已过期以及用于刷新的命令;当所有登录都不可用时列出每个登录的错误 ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156)) +- OMP Agent 新增回合中引导 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) +- OMP 新增 Fast 模式和 Auto 思考级别 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) +- OMP 定时任务和 Hub 运行新增 MCP 服务器 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) +- 新增 Content width 设置,用于设置宽屏上聊天和 Markdown 文件的最大宽度 ([#5680](https://github.com/getpaseo/paseo/pull/5680)) +- 新增 Vue 文件的语法高亮 ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1)) +- 在 `config.json` 中新增 `agents.providers..options`,作为每个 Provider 的默认选项,并支持按 Agent 覆盖 ([#5780](https://github.com/getpaseo/paseo/pull/5780)) +- iPad 和 Android 平板上的终端窗格新增终端按键栏和 Paste ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) + +### 插件 + +- `paseo plugin add owner/slug` 现在从插件注册表安装经过审核的插件,因此本地目录需要使用 `./slug` 这样的路径,直接从 GitHub 安装需要使用 `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224)) +- 插件 SDK 新增[界面以及侧边栏顶部和底部项](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) ([#5685](https://github.com/getpaseo/paseo/pull/5685)) +- 插件 SDK 新增用量来源 ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) +- 插件 SDK 新增 `spawnProcess()`、`execCommand()` 和 `terminateProcess()`,用于启动 CLI,包括 Windows 的 `.cmd` 和 `.bat` 脚本 ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- 插件 SDK 新增 `client.playAudio()`,用于在每个客户端上播放音频 ([#5976](https://github.com/getpaseo/paseo/pull/5976)) +- 新增 `agent.closed` 插件生命周期事件 ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood)) +- 插件 Provider 新增命令和环境变量覆盖以及可用性诊断 ([#5707](https://github.com/getpaseo/paseo/pull/5707)) +- 插件 manifest 新增显示名称、图标、截图和视频 ([#6263](https://github.com/getpaseo/paseo/pull/6263)) +- `paseo plugin init` 新增用于插件注册表列表的 `OVERVIEW.md` 模板 ([#6226](https://github.com/getpaseo/paseo/pull/6226)) + +### 变更 + +- 上下文窗口详情现在显示 Agent 所用账号的用量,包括自定义的 `CLAUDE_CONFIG_DIR` 和 `CODEX_HOME` 目录 ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- 桌面端的上下文窗口详情改为带 Refresh 的悬停卡片 ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185)) +- 上下文窗口计量现在在 Agent 的第一个回合之前就会显示 ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Codex Fast 改为 Speed 菜单,列出每个模型提供的 Normal、Fast 和 Ultrafast 速度;在桌面端显示为闪电图标,选择 Fast 或 Ultrafast 时变为黄色 ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot)) +- Explorer 标签页现在与工作区标签页的行为一致,支持 + 菜单、拖放以及关闭 Files 和 Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942)) +- Pi 和 OMP 的扩展上下文改为显示为可展开的工具行而不是助手文本,并隐藏标记为隐藏的 Pi 消息 ([#6090](https://github.com/getpaseo/paseo/pull/6090)) +- Import session 从侧边栏底部移到了 New workspace 界面 ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- 新安装时侧边栏工作区行默认显示最近活动 ([#6087](https://github.com/getpaseo/paseo/pull/6087)) +- 紧凑侧边栏底部按钮的点击区域改为 44×44 ([#6044](https://github.com/getpaseo/paseo/pull/6044)) +- 从 `origin/main` 或其他远程分支创建的新工作区现在会先获取该分支 ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Paseo 内部用于生成分支名、提交消息和 PR 文本的 Claude Agent 现在在禁用 Claude hooks 的情况下运行 ([#5750](https://github.com/getpaseo/paseo/pull/5750)) +- OMP 审批模式的更改现在在下一次空闲重启时生效,并且在回合进行中会被拒绝 ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Agent、定时任务和 Import session 行中的相对时间戳现在在显示期间会持续更新 ([#5340](https://github.com/getpaseo/paseo/pull/5340)) + +### 修复 + +- 修复了 Claude Opus 4.7 及更高版本上 Claude Agent 从不显示 Thought 行的问题 ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer)) +- 修复了只有后台辅助进程在工作时 Claude Agent 显示为运行中,以及发送消息或 Stop 会终止这些辅助进程的问题 ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson)) +- 修复了 Claude Code 使用 Bedrock 或 Vertex 时 Auto 模式下的 Claude Agent 无法切换模式的问题 ([#6272](https://github.com/getpaseo/paseo/pull/6272)) +- 修复了 Claude 子 Agent 的自动模式报告显示为原始的 SubagentHandback 工具卡片而不是 Markdown 的问题 ([#6248](https://github.com/getpaseo/paseo/pull/6248)) +- 修复了后台 Claude 子 Agent 卡片显示为一直运行的空白 "Task"、daemon 重启后显示为 "Agent",或两个同时启动时没有标签的问题 ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268)) +- 修复了 Claude 空闲时 Claude Code 终端不显示为需要输入的问题 ([#6022](https://github.com/getpaseo/paseo/pull/6022)) +- 修复了 Claude 用量在重试时间之前再次调用被限流的登录的问题 ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- 修复了自行进入计划模式的 Claude Agent 仍显示并保存其之前的模式的问题 ([#6074](https://github.com/getpaseo/paseo/pull/6074)) +- 修复了在会话中更改模式后,恢复的 Agent 回到创建时模式的问题 ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov)) +- 修复了 Windows 上 ACP 插件 Provider 无法启动 `.cmd` 或 `.bat` CLI 的问题 ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- 修复了配置的模型不在 Agent 提供的模型中时 ACP Agent 使用默认模型运行的问题 ([#6273](https://github.com/getpaseo/paseo/pull/6273)) +- 修复了 Provider 从不响应关闭请求时,归档 ACP Agent 后其 Provider 仍在运行的问题 ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang)) +- 修复了 worktree 被移除后已归档的 ACP Agent 显示空白聊天的问题 ([#6228](https://github.com/getpaseo/paseo/pull/6228)) +- 修复了在权限请求未处理时回合失败后,ACP Agent 拒绝之后所有消息的问题 ([#6045](https://github.com/getpaseo/paseo/pull/6045)) +- 修复了报告上下文用量的 ACP Agent 从不显示上下文窗口计量的问题 ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang)) +- 修复了 ACP Agent 拒绝某项会话设置时,定时任务运行和工作区元数据记录为 `[object Object]` 的问题 ([#5765](https://github.com/getpaseo/paseo/pull/5765)) +- 修复了 `omp` 进程崩溃后 OMP Agent 仍绑定在已失效进程上的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx)) +- 修复了 OMP 多选问题显示单选按钮并为每个选项分别提问的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- 修复了 OMP 上的 `/compact` 在 OMP 仍在压缩时于 60 秒后报告失败的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD)) +- 修复了 `paseo import --provider omp` 使用默认模型而不是会话的模型启动 Agent 的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil)) +- 修复了重新加载后 OMP 注入的行显示 `[custom_message]` 前缀的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork)) +- 修复了 OMP 工具行对失败的工具、Task、Wait、网页搜索和抓取显示原始对象或通用标签的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- 修复了 OMP `xd://` 写入的结果从时间线中消失的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) +- 修复了 OMP 模型回退后 Composer 仍显示旧模型的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) +- 修复了打开已归档的 OMP Agent 会启动一个持续运行的 `omp` 进程的问题 ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) +- 修复了运行中包含 Paseo 不显示的消息(例如 OMP 规则提醒)时 OMP 回合一直处于运行中的问题 ([#6112](https://github.com/getpaseo/paseo/pull/6112)) +- 修复了被 OMP 拒绝的提示词(例如缺少 API 密钥)以没有错误的已完成回合结束的问题 ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115)) +- 修复了重新打开的 OMP 聊天对压缩和 OMP 自行写入的其他条目显示 "Custom message" 行的问题 ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215)) +- 修复了模型已被移除的 Pi 和 OMP 会话无法导入或重新打开的问题 ([#6061](https://github.com/getpaseo/paseo/pull/6061)) +- 修复了未安装 `pi-mcp-adapter` 时 Pi 0.99 上的 Pi Agent 启动时没有 Paseo 的 MCP 服务器的问题 ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) +- 修复了正在运行的 Pi 子 Agent 在完成之前显示空白记录的问题 ([#5755](https://github.com/getpaseo/paseo/pull/5755)) +- 修复了分组的 Tintinweb pi-subagents 完成时只把第一个 Agent 标记为已完成,其余仍显示为工作中的问题 ([#5826](https://github.com/getpaseo/paseo/pull/5826)) +- 修复了针对 `paseo_*` 工具的 OpenCode `ask` 规则不经提示就运行工具的问题 ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) +- 修复了 OpenCode 服务器就绪较慢时第一条 OpenCode 提示词要等到事件流超时的问题 ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul)) +- 修复了问题被关闭或工具权限被拒绝后 OpenCode v2 Agent 仍处于运行中的问题 ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188)) +- 修复了 Nix 包无法启动 daemon 和打开终端的问题 ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum)) +- 修复了 OpenCode v2 Agent 在 Nix 包上无法启动的问题 ([#5988](https://github.com/getpaseo/paseo/pull/5988)) +- 修复了每个 Paseo 技能在 Codex 的技能列表中出现两次的问题 ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 Codex 图片生成失败后聊天中没有留下任何行的问题 ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了被拒绝的 Codex 命令或文件编辑在回合结束后仍显示运行中图标的问题 ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了在 Codex 0.156 及更新版本上回退旧版 Codex 聊天失败并提示 `unknown variant thread/rollback` 的问题 ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了包含 `$` 序列的 Codex 自定义提示词参数在传给 Codex 时被修改的问题 ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 schema 中有名为 `properties` 的字段时 Codex 结构化输出失败的问题 ([#5684](https://github.com/getpaseo/paseo/pull/5684)) +- 修复了未设置思考选项而创建的 Codex Agent 在 daemon 重启后丢失默认强度的问题 ([#5686](https://github.com/getpaseo/paseo/pull/5686)) +- 修复了 Codex 请求批准同一条目中的两条命令时只显示一个权限请求的问题 ([#5758](https://github.com/getpaseo/paseo/pull/5758)) +- 修复了在当前版本的 Codex 上 Codex 终端行只显示 "Terminal" 而没有命令的问题 ([#5774](https://github.com/getpaseo/paseo/pull/5774)) +- 修复了 Composer 显示目录默认的思考级别而不是 Agent 实际使用的级别的问题 ([#5804](https://github.com/getpaseo/paseo/pull/5804)) +- 修复了 Agent 的模型不在已加载目录中时 Composer 显示另一个模型标签的问题 ([#5796](https://github.com/getpaseo/paseo/pull/5796)) +- 修复了在 Web 和桌面端向上滚动浏览较早的历史时聊天向回跳几屏的问题 ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172)) +- 修复了聊天中的图片加载完成时下方文本发生移动的问题 ([#5970](https://github.com/getpaseo/paseo/pull/5970)) +- 修复了复制聊天代码块中的全部代码时有时会包含 ``` 围栏行的问题 ([#6138](https://github.com/getpaseo/paseo/pull/6138)) +- 修复了跨越嵌套在编号列表项中的代码块复制时下一项被重新编号的问题 ([#6158](https://github.com/getpaseo/paseo/pull/6158)) +- 修复了跨越图片复制聊天选区时丢失其替代文本和来源,或选区从图片下方开始时却包含该图片的问题 ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195)) +- 修复了聊天中相对的 `path:line` 文件链接打开到该文件第一次被引用的行的问题 ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了包含带链接图片的 PR 评论显示原始 HTML 标签并丢失图片链接的问题 ([#5968](https://github.com/getpaseo/paseo/pull/5968)) +- 修复了浏览器工具输入的文本出现在你的消息框而不是浏览器标签页中的问题 ([#6207](https://github.com/getpaseo/paseo/pull/6207)) +- 修复了 Astro 表达式在嵌套模板字符串之后失去颜色的问题 ([#6219](https://github.com/getpaseo/paseo/pull/6219)) +- 修复了下载名称包含非 ASCII 字符的文件的问题 ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd)) +- 修复了在其他应用语言下 PR 面板、侧边栏工作区状态和 "Worked for" 行显示英文的问题 ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq)) +- 修复了读起来像逐字机器翻译的法语翻译 ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn)) +- 修复了滚动长聊天后无法从工作区标题栏拖动桌面窗口的问题 ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen)) +- 修复了在桌面布局的触摸屏上打开 Agent 时弹出屏幕键盘的问题 ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu)) +- 修复了 macOS 终端中 Option+方向键、Cmd+方向键和 Cmd+Backspace 的行编辑 ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio)) +- 修复了 Add Project 的目录搜索遇到读取永不返回的文件夹时 daemon 冻结的问题 ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone)) +- 修复了 `paseo.json` 无法解析时新的 worktree 工作区被归档的问题 ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了工作区脚本状态在其他已连接的客户端中不更新的问题 ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart)) +- 修复了 Agent 和终端继承语音库路径,导致 Windows 上 Git Bash 的 PATH 被截断的问题 ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898)) +- 修复了 daemon 在解压本地语音模型时停止后,语音输入一直失败直到手动删除该模型的问题 ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了网络不提供主机名时 macOS 主机显示为 `UNKNOWN` 的问题 ([#5922](https://github.com/getpaseo/paseo/pull/5922)) +- 修复了主机从睡眠中唤醒后 `paseo daemon status` 把 Linux 虚拟机中的 daemon 报告为已停止的问题 ([#6279](https://github.com/getpaseo/paseo/pull/6279)) +- 修复了中继连接在握手之前停滞后,中继重新连接被忽略的问题 ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了启动 `paseo daemon run` 的程序停止读取其输出时,它以 "Daemon failed to start" 退出的问题 ([#6117](https://github.com/getpaseo/paseo/pull/6117)) +- 修复了输出不再被读取时 CLI 命令因 `EPIPE` 崩溃,以及 Windows 桌面应用显示错误对话框的问题 ([#6097](https://github.com/getpaseo/paseo/pull/6097)) +- 修复了以链接方式全局安装时 daemon 自更新给出错误的拒绝原因的问题 ([#6095](https://github.com/getpaseo/paseo/pull/6095)) +- 修复了 npm 安装在非 npm 全局前缀下时 daemon 自更新失败的问题 ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了打开 worktree 已被移除的已归档 Agent 时,如果工作区没有记录分支,会显示 "Workspace unavailable" 的问题 ([#5694](https://github.com/getpaseo/paseo/pull/5694)) +- 修复了向目录已被移除的已归档 Agent 发送失败后,它从 History 中消失的问题 ([#6255](https://github.com/getpaseo/paseo/pull/6255)) +- 修复了 daemon 重启后主机页面上的主机版本仍是旧版本的问题 ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) +- 修复了 Remote SSH 主机无法连接到受密码保护的 daemon 的问题,并新增 Daemon password 字段 ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart)) +- 修复了在第一次连接尝试期间移除主机不会停止该连接的问题 ([#5722](https://github.com/getpaseo/paseo/pull/5722)) +- 修复了在重新连接期间开始的文件上传失败并提示 `Connection changed during file upload` 的问题 ([#5585](https://github.com/getpaseo/paseo/pull/5585)) +- 修复了平板上键盘打开时居中的 New workspace 表单折叠并重叠的问题 ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了 Android 侧边栏在选择工作区后重新打开时跳回顶部的问题 ([#6058](https://github.com/getpaseo/paseo/pull/6058)) +- 修复了在原生平板上点击上下文圆环和长按工作区标签页没有反应的问题 ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) +- 修复了 iPad 聊天文本在窗格调整大小后保持旧宽度的问题 ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- 修复了 Web 和桌面端的上下文圆环从三点钟方向而不是十二点钟方向开始填充的问题 ([#5598](https://github.com/getpaseo/paseo/pull/5598)) +- 修复了应用缩放时浏览器标注截图截取错误区域的问题 ([#5703](https://github.com/getpaseo/paseo/pull/5703)) +- 修复了没有本地 daemon 运行时桌面应用每秒启动一个 `daemon status` 进程的问题 ([#5636](https://github.com/getpaseo/paseo/pull/5636)) +- 修复了在选择项目或模型之前,草稿中的 `/` 菜单列出 `/clear` 和 `/exit` 的问题 ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了 `paseo agent delete --all` 和 `--cwd` 遗留已归档 Agent 的问题 ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 `paseo agent` 命令对未知的 Agent ID 报告通用失败而不是 `AGENT_NOT_FOUND` 的问题 ([#6053](https://github.com/getpaseo/paseo/pull/6053)) +- 修复了 `paseo agent open` 对不存在的 Agent 打开 Desktop 并以 0 退出的问题 ([#5727](https://github.com/getpaseo/paseo/pull/5727)) +- 修复了 Provider 检查耗时超过一秒时 `paseo restart` 以 `RESTART_NOT_CONFIRMED` 退出的问题 ([#6009](https://github.com/getpaseo/paseo/pull/6009)) +- 修复了 `paseo schedule update --no-max-runs` 和 `--no-expires-in` 在到达 daemon 之前就失败的问题 ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet)) +- 修复了恢复的 Claude Agent 的对话记录缺失或无法读取时 `daemon.log` 中没有任何记录的问题 ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) +- 修复了重新加载插件会在其 Provider 的已完成聊天中添加 `[System Error] Provider connection closed` 的问题 ([#5579](https://github.com/getpaseo/paseo/pull/5579)) +- 修复了 `paseo plugin add .` 在 daemon 的目录而不是 CLI 的目录中查找插件的问题 ([#6239](https://github.com/getpaseo/paseo/pull/6239)) +- 修复了桌面端的插件设置界面无法返回 Plugins 页面的问题 ([#5620](https://github.com/getpaseo/paseo/pull/5620)) +- 修复了插件客户端代码无法导入只声明了 `main` 或 `module` 的 npm 包的问题 ([#5838](https://github.com/getpaseo/paseo/pull/5838)) +- 修复了 SDK 的 `send()` 和 `run()` 在 TypeScript 中拒绝 `activeTurnBehavior` 的问题 ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) +- 修复了从 npm 打包 `@getpaseo/client` 时失败并提示 `Could not resolve "@getpaseo/relay/e2ee"` 的问题 ([#5815](https://github.com/getpaseo/paseo/pull/5815)) + +## 0.10.3 - 2026-10-02 + +### 新增 + +- 配对链接连接到新主机,或连接到密钥或中继已变化的已保存主机之前,新增确认步骤 ([#5753](https://github.com/getpaseo/paseo/pull/5753)) + +## 0.10.2 - 2026-09-30 + +### 修复 + +- 修复了超过五分钟的 OpenCode v2 回合失败并提示 `UND_ERR_HEADERS_TIMEOUT` 的问题 ([#5674](https://github.com/getpaseo/paseo/pull/5674)) +- 修复了上下文计量在 OpenCode v2 回合期间一直为空并在回合结束后消失的问题 ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger)) +- 修复了 OpenCode v2 问题卡片只显示标题且无法输入答案的问题 ([#5679](https://github.com/getpaseo/paseo/pull/5679)) +- 修复了来自 GPT 模型的 OpenCode v2 补丁编辑显示为原始 Patch 卡片而不是编辑 diff 的问题 ([#5696](https://github.com/getpaseo/paseo/pull/5696)) +- 修复了已完成的 OpenCode v2 编辑不显示 diff 的问题 ([#5609](https://github.com/getpaseo/paseo/pull/5609)) + +## 0.10.1 - 2026-09-29 + +### 新增 + +- 为 Claude Code 2.1.284 及更新版本新增 Claude Sonnet 5.5 ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi)) + +### 修复 + +- 修复了切换到不支持所选思考级别的模型后 OpenCode 聊天失败并提示 "Variant unavailable" 的问题 ([#5587](https://github.com/getpaseo/paseo/pull/5587)) +- 修复了回退 Codex 聊天会丢失其自定义 Provider 和 Paseo 工具的问题 ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg)) +- 修复了流式回复在聊天重新加载之前把代码块和 Mermaid 图表的行连在一起的问题 ([#5577](https://github.com/getpaseo/paseo/pull/5577)) +- 修复了从拆分窗格打开的子 Agent 在另一个窗格中打开的问题 ([#5451](https://github.com/getpaseo/paseo/pull/5451)) +- 修复了归档自定义 Codex Provider 的 Agent 后其会话仍留在 Import session 中的问题 ([#5572](https://github.com/getpaseo/paseo/pull/5572)) +- 修复了使用自己 `CODEX_HOME` 的自定义 Codex Provider 的 `/` 菜单列出 daemon 的提示词而不是它自己的提示词的问题 ([#5450](https://github.com/getpaseo/paseo/pull/5450)) +- 修复了后台 `send_agent_prompt` 对 Agent 已接受的提示词返回 `idle` 的问题 ([#5386](https://github.com/getpaseo/paseo/pull/5386)) +- 修复了 Windows 上工作区内文件的文件链接提示显示完整路径的问题 ([#1987](https://github.com/getpaseo/paseo/pull/1987)) +- 修复了运行 `cursor-agent` 的终端配置显示通用终端图标的问题 ([#5379](https://github.com/getpaseo/paseo/pull/5379)) + +## 0.10.0 - 2026-09-28 + +### 新增 + +- 新增 OpenCode v2 支持,会根据已安装的 `opencode` 版本自动选择 ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep)) +- Pi 聊天新增来自 rpiv-todo、pi-goal-x 和 Pi 示例 todo 扩展的任务列表 ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Subagents 栏新增来自 pi-subagents、Tintinweb pi-subagents 和 Gotgenes pi-subagents 的子 Agent 运行 ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Pi 聊天新增 rpiv-ask-user-question 对话框,以一个问题表单的形式显示 ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- 中继连接新增密码校验,会拒绝错误的 daemon 密码;后续版本将强制要求密码 ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Add host、配对链接和二维码配对新增密码字段,用于受密码保护的 daemon ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- `paseo daemon pair` 新增 `relay://` 连接字符串 ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- 在 OMP 17.4.2 及更新版本上,问题卡片新增 OMP Ask 选项描述 ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull)) + +### 变更 + +- CLI 和桌面应用现在连接同一台机器上受密码保护的 daemon 时不再询问密码 ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- 因密码被拒绝的主机现在会在主机和 Connections 页面上显示 "Password required" 或 "Incorrect password" ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Settings 重新组织为 General、Sidebar、Chat、Terminal、Browser 和 Open location 页面 ([#5459](https://github.com/getpaseo/paseo/pull/5459)) + +### 修复 + +- 修复了 daemon 密码包含空格或 `@`、`/` 等字符时直接连接失败的问题 ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- 修复了 OpenCode 服务器重启后现有 OpenCode Agent 以 `ECONNREFUSED` 失败的问题 ([#5338](https://github.com/getpaseo/paseo/pull/5338)) +- 修复了 OpenCode 自行添加的消息在聊天中显示得像是用户输入的问题 ([#5434](https://github.com/getpaseo/paseo/pull/5434)) +- 修复了 `daemon.log` 无法写入时(例如磁盘已满)daemon 停止的问题 ([#5445](https://github.com/getpaseo/paseo/pull/5445)) +- 修复了打开 worktree 已被移除的已归档 ACP Agent 时 daemon 崩溃的问题 ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid)) +- 修复了应用启动后(包括之后的重启)现有工作区从侧边栏中消失的问题 ([#5394](https://github.com/getpaseo/paseo/pull/5394)) +- 修复了使用自己 `CLAUDE_CONFIG_DIR` 的自定义 Claude Provider 上的 Agent 在 daemon 重启后打开为空白聊天的问题 ([#5437](https://github.com/getpaseo/paseo/pull/5437)) +- 修复了自定义 Codex Provider 的会话不出现在 Import session 中的问题 ([#5446](https://github.com/getpaseo/paseo/pull/5446)) +- 修复了 Provider 的版本检查耗时超过 1.5 秒时主机设置中的 Update daemon 和 Restart 失败的问题 ([#5372](https://github.com/getpaseo/paseo/pull/5372)) +- 修复了在 Agent 会话内执行 `paseo --host run` 失败并提示 "Caller agent not found" 的问题 ([#5392](https://github.com/getpaseo/paseo/pull/5392)) +- 修复了之前回退过之后,Pi 聊天回退到比所选消息更早位置的问题 ([#5383](https://github.com/getpaseo/paseo/pull/5383)) +- 修复了 daemon 重启时 Pi 聊天的回退被撤销的问题 ([#5432](https://github.com/getpaseo/paseo/pull/5432)) +- 修复了未指定模型启动的 Pi Agent 忽略 Pi 配置的默认模型的问题 ([#5343](https://github.com/getpaseo/paseo/pull/5343)) +- 修复了检出切换分支后新 Agent 的 `/` 菜单中的项目技能仍是旧内容的问题 ([#5415](https://github.com/getpaseo/paseo/pull/5415)) +- 修复了自定义 ACP Provider 上新 Agent 的 `/` 菜单中缺少斜杠命令的问题 ([#5411](https://github.com/getpaseo/paseo/pull/5411)) +- 修复了向正在运行的子 Agent 发送提示词后父 Agent 收到两次子 Agent 完成通知的问题 ([#5407](https://github.com/getpaseo/paseo/pull/5407)) +- 修复了超过 30 秒等待时间的阻塞式 `send_agent_prompt` 在子 Agent 完成时从不通知调用方的问题 ([#5347](https://github.com/getpaseo/paseo/pull/5347)) +- 修复了 OSC 8 终端链接显示导航提示并打开空白 Paseo 窗口的问题 ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506)) +- 修复了插件主题超出窗口时 Theme 菜单无法滚动的问题 ([#5374](https://github.com/getpaseo/paseo/pull/5374)) +- 修复了上周同一星期几的消息只显示星期几而不显示日期的问题 ([#5341](https://github.com/getpaseo/paseo/pull/5341)) +- 修复了 stdin 不是终端时 `paseo daemon set-password` 静默退出的问题 ([#5358](https://github.com/getpaseo/paseo/pull/5358)) + +## 0.9.2 - 2026-09-24 + +### 新增 + +- 新增结构化的 Claude Code 启动参数,用于会话配置和插件 ([#5206](https://github.com/getpaseo/paseo/pull/5206)) + +### 改进 + +- 减少了对 watcher 无法观察的仓库的后台 Git 轮询 ([#5170](https://github.com/getpaseo/paseo/pull/5170)) +- 缩短了在大型主目录上 Add Project 目录搜索的时间 ([#5190](https://github.com/getpaseo/paseo/pull/5190)) + +### 修复 + +- 修复了工作区打开后出现被忽略的目录时 daemon 失去响应的问题 ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172)) +- 修复了客户端连接关闭后 daemon 内存增长的问题 ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346)) +- 修复了以空格结尾的项目文件夹导致应用在加载时崩溃的问题 ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB)) +- 修复了 Agent 界面先于侧边栏打开时工作区标签缺失的问题 ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai)) +- 修复了工作区所在的磁盘或网络共享不可用时工作区消失的问题 ([#5227](https://github.com/getpaseo/paseo/pull/5227)) +- 修复了会话导入失败导致已归档的 worktree 无法恢复的问题 ([#5238](https://github.com/getpaseo/paseo/pull/5238)) +- 修复了 Paseo 移除 worktree 后已归档 Agent 的日志读取失败的问题 ([#5229](https://github.com/getpaseo/paseo/pull/5229)) +- 修复了新创建的分支通过继承的上游推送到默认分支的问题 ([#5249](https://github.com/getpaseo/paseo/pull/5249)) +- 修复了刷新后 fork 检出的拉取请求从工作区中消失的问题 ([#5221](https://github.com/getpaseo/paseo/pull/5221)) +- 修复了 Agent 创建的本地工作区接受不存在的路径或文件的问题 ([#5322](https://github.com/getpaseo/paseo/pull/5322)) +- 修复了聊天上传把原始文件名中的有效字符替换为下划线的问题 ([#5317](https://github.com/getpaseo/paseo/pull/5317)) +- 修复了多选问题丢失已勾选的选项或输入的 Other 答案的问题 ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer)) +- 修复了底部面板打开时按 Android 返回键会离开当前界面的问题 ([#5245](https://github.com/getpaseo/paseo/pull/5245)) +- 修复了语音模式在语音回复的各段之间播放思考提示音的问题 ([#5281](https://github.com/getpaseo/paseo/pull/5281)) +- 修复了带附件发送的 Claude 斜杠命令以纯文本到达 Claude 的问题 ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton)) +- 修复了在没有收到响应的回合之后回退 Claude 的问题 ([#5285](https://github.com/getpaseo/paseo/pull/5285)) +- 修复了在包含子 Agent 消息的回合之后回退 Claude 的问题 ([#5289](https://github.com/getpaseo/paseo/pull/5289)) +- 修复了配置了持久时间线存储时刷新后 Agent 历史重复的问题 ([#5286](https://github.com/getpaseo/paseo/pull/5286)) +- 修复了 OMP 自定义消息在 Provider 完成之前结束回合的问题 ([#3258](https://github.com/getpaseo/paseo/pull/3258)) +- 修复了被停止的 OMP 回合显示为失败回合的问题 ([#5243](https://github.com/getpaseo/paseo/pull/5243)) +- 修复了运行时已退出的 Pi 或 OMP Agent 无法通过 Stop 结束的问题 ([#5235](https://github.com/getpaseo/paseo/pull/5235)) +- 修复了 OpenCode Agent 忽略其配置的权限规则的问题 ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion)) +- 修复了 Codex 的 Default 和 Read-only 模式把审批请求发送给 Auto-review 的问题 ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS)) +- 修复了从 Fast 模型切换到不支持 Fast 的模型后创建 Cursor Agent 的问题 ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen)) +- 修复了 Codex 中 GPT-6 Sol 和 GPT-6 Luna 缺少 Fast 控件的问题 ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了 Claude 配置的 Fable 模型不在模型选择器中的问题 ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9)) +- 修复了 daemon 启动的 ACP 终端使用错误的 Agent 身份的问题 ([#5248](https://github.com/getpaseo/paseo/pull/5248)) +- 修复了 daemon 重启后 Agent 已完成的插件会话显示为失败的问题 ([#5253](https://github.com/getpaseo/paseo/pull/5253)) +- 修复了 Provider 会话关闭期间重新加载插件导致其子进程崩溃的问题 ([#5231](https://github.com/getpaseo/paseo/pull/5231)) +- 修复了插件 Provider 请求失败导致 daemon 崩溃的问题 ([#5298](https://github.com/getpaseo/paseo/pull/5298)) +- 修复了 Hub 执行无法为其创建的工作区设置标题的问题 ([#5302](https://github.com/getpaseo/paseo/pull/5302)) +- 修复了无效的定时任务文件导致 daemon 无法启动的问题 ([#5301](https://github.com/getpaseo/paseo/pull/5301)) +- 修复了空的 `paseo.pid` 导致 daemon 无法启动的问题 ([#5306](https://github.com/getpaseo/paseo/pull/5306)) +- 修复了重启后被复用的 daemon PID 导致无法启动的问题 ([#5277](https://github.com/getpaseo/paseo/pull/5277)) +- 修复了 `config.json` 带有 UTF-8 BOM 时 daemon 启动失败的问题 ([#5315](https://github.com/getpaseo/paseo/pull/5315)) +- 修复了后台 daemon 的启动错误没有出现在报告的日志文件中的问题 ([#5332](https://github.com/getpaseo/paseo/pull/5332)) +- 修复了针对无效 `config.json` 的 CLI 错误没有指出文件和出错字段的问题 ([#5337](https://github.com/getpaseo/paseo/pull/5337)) +- 修复了密码被拒绝后 CLI 仍建议启动 daemon 的问题 ([#5310](https://github.com/getpaseo/paseo/pull/5310)) +- 修复了受密码保护的桌面 daemon 缺少 Open in editor 的问题 ([#5335](https://github.com/getpaseo/paseo/pull/5335)) +- 修复了 `paseo permit ls --json` 截短了 `permit allow` 和 `permit deny` 所需的请求 ID 的问题 ([#5305](https://github.com/getpaseo/paseo/pull/5305)) +- 修复了副本缓存的存储持续拒绝写入时读取一直空转的问题 ([#5290](https://github.com/getpaseo/paseo/pull/5290)) +- 修复了 MiniMax 卡片对未激活的 token 订阅显示原始错误的问题 ([#5258](https://github.com/getpaseo/paseo/pull/5258)) +- 修复了带修饰键的 Backspace 无法设为自定义快捷键的问题 ([#5224](https://github.com/getpaseo/paseo/pull/5224)) +- 修复了多步快捷键在两步之间应用更新时失败的问题 ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了第二步按住修饰键时多步快捷键失败的问题 ([#5272](https://github.com/getpaseo/paseo/pull/5272)) +- 修复了重新绑定的窗格聚焦快捷键在输入时失效的问题 ([#5287](https://github.com/getpaseo/paseo/pull/5287)) + +## 0.9.1 - 2026-09-22 + +### 新增 + +- 在 Claude Code 2.1.280 及更新版本上,Claude 目录新增 Opus 5.5 作为默认模型,支持 1M 上下文窗口和 Fast Mode ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri)) + +### 修复 + +- 修复了应用重新连接 daemon 后侧边栏只保留最近有变化的对话的问题 ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu)) +- 修复了 Import session 只提供最新的 100 个 Codex 对话的问题 ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 ID 带有次版本号的 Claude 模型(例如 Opus 5.5)在回合结束后于 Composer 中回退为其主版本的问题 ([#5200](https://github.com/getpaseo/paseo/pull/5200)) + +## 0.9.0 - 2026-09-22 + +### 新增 + +- 文件窗格新增 Cmd/Ctrl+F Find,可在可编辑文件中替换 ([#4589](https://github.com/getpaseo/paseo/pull/4589)) +- 终端回滚缓冲区新增 Cmd/Ctrl+F Find ([#4650](https://github.com/getpaseo/paseo/pull/4650)) +- 聊天新增 Cmd/Ctrl+F Find,包括已加载历史窗口之外的消息 ([#4765](https://github.com/getpaseo/paseo/pull/4765)) +- 移动端 Changes 新增 Jump to file,在底部面板中打开已更改文件树 ([#4861](https://github.com/getpaseo/paseo/pull/4861)) +- 检测到 PR 时,每个工作区会自动打开一次 Pull request 标签页 ([#4956](https://github.com/getpaseo/paseo/pull/4956)) +- 新增可展开的计划卡片,被拒绝的计划默认折叠 ([#4756](https://github.com/getpaseo/paseo/pull/4756)) +- 所选文件上传期间新增带加载图标的附件占位符 ([#4958](https://github.com/getpaseo/paseo/pull/4958)) +- History 搜索中的工作区、Agent、项目和分支字段新增匹配高亮 ([#4945](https://github.com/getpaseo/paseo/pull/4945)) + +### 插件 + +- 新增从 npm 安装插件,包括有作用域的包、版本、标签和范围 ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- 新增 `paseo plugin update`,支持 `--all`、`--check` 和 `--yes`,并在批准之前显示当前和拟更新的修订版本 ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Settings → Plugins 新增每个插件的描述、来源和已安装的修订版本 ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- `navigation.openAgent()` 和 `navigation.openWorkspace()` 新增可选的 `serverId` ([#4942](https://github.com/getpaseo/paseo/pull/4942)) +- 新增通过 `useHosts()` 发现主机,以及通过 `getPaseoClient(serverId)` 获取指定主机的 SDK 客户端 ([#4971](https://github.com/getpaseo/paseo/pull/4971)) +- 新增 `openExternalUrl()` 和 ``,用于在 Paseo 之外打开 URL ([#4972](https://github.com/getpaseo/paseo/pull/4972)) +- 新增 `navigation.openBrowser()`,用于在桌面端的工作区浏览器中打开 URL ([#4972](https://github.com/getpaseo/paseo/pull/4972)) +- `registerSettings()` 现在返回一个带有 `read()` 和 `subscribe()` 的服务端设置句柄 ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger)) +- `assistant_message` 转换器现在在每次更新时都会收到完整的累计消息 ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork)) +- `tool_call` 转换器现在会在 Overview 分组之前收到每一个原始调用 ([#4675](https://github.com/getpaseo/paseo/pull/4675)) +- 修复了插件会话的心跳租约过期后永久失去主机 API 的问题 ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio)) +- 修复了 Windows 上插件构建命令以 `spawn npm ENOENT` 失败的问题 ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati)) +- 修复了没有 `messageId` 的 ACP 文本块把一条回复拆成每块一条消息的问题 ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB)) +- 修复了嵌套的 Provider 子 Agent 显示为根 Agent 直接子项的问题 ([#4970](https://github.com/getpaseo/paseo/pull/4970)) + +### 变更 + +- History 搜索现在在按日期分组的结果中保持时间顺序,而不是按相关性重新排序 ([#4945](https://github.com/getpaseo/paseo/pull/4945)) +- 工作区的 Restore 现在保持选中已归档的 Agent,并提供单独的 Unarchive 操作 ([#4736](https://github.com/getpaseo/paseo/pull/4736)) +- 工作区错误界面的 Retry 改为 Reload,会重新打开到项目选择器 ([#4598](https://github.com/getpaseo/paseo/pull/4598)) +- 关闭最后一个内容标签页后现在会留下 New 启动器,而不是一个无法使用的窗格 ([#4844](https://github.com/getpaseo/paseo/pull/4844)) +- `paseo daemon start` 现在读取持久化的配置;已移除的配置标志会失败并给出迁移说明 ([#4575](https://github.com/getpaseo/paseo/pull/4575)) + +### 改进 + +- 回复流式输出时,粗体、斜体、删除线、行内代码和链接标签会保持格式 ([#4742](https://github.com/getpaseo/paseo/pull/4742)) +- 一条三句话的回复,第一段语音音频的等待时间从 4.80 秒缩短到 0.95 秒 ([#4927](https://github.com/getpaseo/paseo/pull/4927)) +- 在包含 213 个文件的工作区中,冷启动生成 diff 的时间从 11.45 秒缩短到 2.65 秒 ([#4676](https://github.com/getpaseo/paseo/pull/4676)) +- 降低了桌面端内存占用:加载 daemon 管理后,Electron 主进程的 RSS 从 290.5 MiB 降至 152.1 MiB ([#5007](https://github.com/getpaseo/paseo/pull/5007)) +- 打开的聊天在视图被回收、应用进入后台和重新连接时保持订阅 ([#4863](https://github.com/getpaseo/paseo/pull/4863)) +- 聊天 toast 新增重新连接和 Updating messages 状态 ([#4863](https://github.com/getpaseo/paseo/pull/4863)) +- 大文件上传期间在每个 128 KiB 分块之间让出主线程,使应用保持响应 ([#4958](https://github.com/getpaseo/paseo/pull/4958)) +- 桌面窗口隐藏时,常驻的浏览器页面在两次截图之间进入空闲 ([#4646](https://github.com/getpaseo/paseo/pull/4646)) + +### 修复 + +- 修复了长对话中工具输出累积导致 daemon 堆内存耗尽的问题 ([#4838](https://github.com/getpaseo/paseo/pull/4838)) +- 修复了重复提交为同一意图创建多个工作区或 Agent 的问题 ([#4442](https://github.com/getpaseo/paseo/pull/4442)) +- 修复了关闭工作区中最后一个内容标签页后陷入崩溃循环的问题 ([#4844](https://github.com/getpaseo/paseo/pull/4844)) +- 修复了已启用的 Provider 发布重复的模型 ID 时 New Agent 崩溃的问题 ([#4839](https://github.com/getpaseo/paseo/pull/4839)) +- 修复了 Android 和 iOS 上长草稿延伸到聊天标题栏后面的问题 ([#4824](https://github.com/getpaseo/paseo/pull/4824)) +- 修复了键盘关闭时 Android Composer 变高的问题 ([#4902](https://github.com/getpaseo/paseo/pull/4902)) +- 修复了长按删除清空草稿后 Android Composer 仍保持原高度的问题 ([#4946](https://github.com/getpaseo/paseo/pull/4946)) +- 修复了 Composer 变高时 New workspace 设置内容不向上移动的问题 ([#4973](https://github.com/getpaseo/paseo/pull/4973)) +- 修复了在聊天中点击或选择文本时 Android 时间线滚走的问题 ([#5013](https://github.com/getpaseo/paseo/pull/5013)) +- 修复了 Android 上 Paste image 在附件到达 Composer 之前就失败的问题 ([#4758](https://github.com/getpaseo/paseo/pull/4758)) +- 修复了 Android 平板上 Provider 和模型选择器没有响应的问题 ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl)) +- 修复了 Linux 桌面包在没有 Chromium 沙箱的情况下启动的问题 ([#4447](https://github.com/getpaseo/paseo/pull/4447)) +- 修复了 `paseo daemon stop` 关闭了所选 daemon 之外的 daemon 的问题 ([#4575](https://github.com/getpaseo/paseo/pull/4575)) +- 修复了恢复的已归档工作区显示空的 Changes 和 Commits 的问题 ([#4926](https://github.com/getpaseo/paseo/pull/4926)) +- 修复了被拒绝的计划显示在拒绝它的后续消息下方的问题 ([#4756](https://github.com/getpaseo/paseo/pull/4756)) +- 修复了 Composer 获得焦点时 Cmd/Ctrl+F 无法打开聊天 Find 的问题 ([#4991](https://github.com/getpaseo/paseo/pull/4991)) +- 修复了分支的远程是仓库 URL 时检测不到关联拉取请求的问题 ([#4862](https://github.com/getpaseo/paseo/pull/4862)) +- 修复了插件过滤后的 Agent 列表替换了应用自己的目录订阅的问题 ([#4596](https://github.com/getpaseo/paseo/pull/4596)) +- 修复了更新后的应用拒绝不支持独立订阅的 daemon 的问题 ([#4737](https://github.com/getpaseo/paseo/pull/4737)) +- 修复了在 0.8.0 及更早的 daemon 上创建工作区和 Agent 失败,或 Agent 标题停留在 "Loading…" 的问题 ([#4895](https://github.com/getpaseo/paseo/pull/4895)) +- 修复了 Cursor 模型显示另一个模型的思考选项的问题 ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix)) +- 修复了 Pi 模型选择器提供模型不支持的思考级别的问题 ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme)) +- 修复了 Pi 会话报告请求的思考级别而不是 Pi 实际应用的级别的问题 ([#4413](https://github.com/getpaseo/paseo/pull/4413)) +- 修复了语音聊天的用户消息显示内部提示词包装而不是转写文本的问题 ([#4927](https://github.com/getpaseo/paseo/pull/4927)) +- 修复了 History 和 Command Center 搜索匹配由不同单词中的字母拼成的查询的问题 ([#4945](https://github.com/getpaseo/paseo/pull/4945)) +- 修复了触摸布局中工作区标题因隐藏的 diff 统计而过早截断的问题 ([#4698](https://github.com/getpaseo/paseo/pull/4698)) +- 修复了拖动打开 Explorer 时显示 `+0 -0` 和 "No changes" 的问题 ([#4861](https://github.com/getpaseo/paseo/pull/4861)) +- 修复了打开已保存的聊天时重新连接 toast 重新播放进入动画的问题 ([#4925](https://github.com/getpaseo/paseo/pull/4925)) +- 修复了已更改文件底部面板中 Android 文件行周围出现凸起阴影的问题 ([#4898](https://github.com/getpaseo/paseo/pull/4898)) + +## 0.8.0 - 2026-09-10 + +Paseo 0.8 新增插件标题栏按钮、自定义 Provider 和更丰富的聊天组件,同时修复了桌面端更新和移动端键盘的问题。 + +**升级前须知**:macOS 桌面应用现在需要 macOS 13 或更新版本;Linux 和 Windows 的支持不变。为 0.7 编写的插件需要参考 [0.8 迁移指南](https://paseo.sh/docs/plugins/v0.8/migration),包括分离的客户端/服务端入口以及修订后的 Composer 胶囊 API。 + +### 新增 + +- 新增 Agent 继续工作时 Codex 提出的问题的回答表单 ([#4587](https://github.com/getpaseo/paseo/pull/4587)) +- 侧边栏菜单中已完成的工作区新增 Mark as unread ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj)) +- 在帮助菜单、Settings → About 和桌面端更新提示中新增应用内的 What's new 面板 ([#4576](https://github.com/getpaseo/paseo/pull/4576)) +- 侧边栏底部、History 和 Command Center 新增 Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Import session 新增搜索、分页和按工作区过滤 ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Appearance 设置新增侧边栏项的排序和可见性控制 ([#4203](https://github.com/getpaseo/paseo/pull/4203)) +- 时间线新增来自 Pi 扩展和 OpenCode 的 Provider 通知 ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) +- Web 和桌面端新增全屏 Mermaid 查看器 ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) +- Web diff 的右键菜单新增 Copy、Copy line 和 Select all ([#4229](https://github.com/getpaseo/paseo/pull/4229)) +- ACP Provider 目录新增 Gajae Code ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo)) +- 新增按 Provider 禁用全部或部分 Paseo 工具的控制 ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) +- 在 fork 拉取请求的工作区中运行设置、自动终端或脚本之前新增显式批准 ([#4215](https://github.com/getpaseo/paseo/pull/4215)) +- 新增 Hub 对现有 Agent 的后续消息,包括恢复已归档的工作区 ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Hub 执行新增 Provider 目录发现 ([#4242](https://github.com/getpaseo/paseo/pull/4242)) + +### 插件 + +- 新增自定义 Provider,可以有自己的图标、设置、权限和时间线渲染 ([#4314](https://github.com/getpaseo/paseo/pull/4314)) +- 新增插件重新加载后的 Provider 会话恢复 ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger)) +- 新增自定义时间线组件和持久化的时间线行 ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- 新增无需 Provider 回合即可运行的客户端斜杠命令 ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- 在 Settings → Plugins 下新增插件设置界面 ([#4357](https://github.com/getpaseo/paseo/pull/4357)) +- 新增 Agent 创建、回合、权限和归档事件的生命周期 hook ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- 新增针对新 Agent 的 Provider、模型、环境变量、MCP 服务器和工作区隔离的转换 ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- 新增标题栏按钮和 Composer 胶囊,支持操作、菜单、自定义弹出框和实时更新 ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- 新增自定义弹窗主体,支持滚动、感知键盘的输入框和剪贴板操作 ([#4392](https://github.com/getpaseo/paseo/pull/4392)) +- SDK 新增终端的创建、输入、捕获和管理 ([#4358](https://github.com/getpaseo/paseo/pull/4358)) +- 新增通过 `paseo.projects.subscribe()` 实时订阅项目 ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) +- 新增通过 `agent.respondToPermission()` 响应权限请求 ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) +- 新增通过 `providers.listUsage()` 查询 Provider 用量 ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov)) +- 插件 manifest 新增支持的 Paseo 版本要求 ([#4430](https://github.com/getpaseo/paseo/pull/4430)) +- 插件现在分为客户端和服务端入口,并使用针对各自运行时的 SDK 导入 ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) +- Composer 胶囊注册改为使用 `button`、`registration.update()` 和 `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- `paseo plugin ls` 现在显示运行时状态、已安装的提交和加载错误,而无需联系远程 ([#4265](https://github.com/getpaseo/paseo/pull/4265)) +- 修复了 Explorer 菜单中缺少工作区插件面板的问题 ([#4446](https://github.com/getpaseo/paseo/pull/4446)) + +### 变更 + +- 桌面应用现在需要 macOS 13 或更新版本;macOS 12 将不再收到桌面端更新 ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- `--host` 改为全局 CLI 选项:`paseo --host ` ([#4238](https://github.com/getpaseo/paseo/pull/4238)) +- 侧边栏底部的 Home 按钮改为 Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- `paseo hub init` 和默认的 `paseo hub deploy` 现在使用 `.paseo/triggers/` 中的组织触发器 ([#4517](https://github.com/getpaseo/paseo/pull/4517)) +- Codex Fast 的可用性改为基于受支持的模型列表,包括 GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640)) +- CLI 安装现在解析到打过补丁的 React 19.1.x 版本 ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) + +### 改进 + +- 回到应用时立即重新连接所有主机 ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- 时间线和 Composer 一起移动,使移动端键盘过渡更平滑 ([#4275](https://github.com/getpaseo/paseo/pull/4275)) +- 减少了打开大型 diff 时的卡顿 ([#4574](https://github.com/getpaseo/paseo/pull/4574)) +- 只重新加载配置发生变化的 Provider ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- 移除了空闲 SDK 连接上未请求的时间线和目录流量 ([#4470](https://github.com/getpaseo/paseo/pull/4470)) +- App Diagnostics 新增 macOS 更新程序的诊断信息 ([#4322](https://github.com/getpaseo/paseo/pull/4322)) + +### 修复 + +- 修复了 macOS 桌面端更新关闭应用却没有安装新版本的问题 ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- 修复了发布清单可用之前桌面更新程序就报告已是最新的问题 ([#4201](https://github.com/getpaseo/paseo/pull/4201)) +- 修复了在 Linux `.deb`、RPM 和 tarball 安装中,从桌面端安装的 CLI 执行 `paseo restart` 失败的问题 ([#4207](https://github.com/getpaseo/paseo/pull/4207)) +- 修复了打包后的 macOS Dock 图标显示过大的问题 ([#4389](https://github.com/getpaseo/paseo/pull/4389)) +- 修复了使用工具栏按键或 Enter 时移动端终端键盘关闭又重新打开的问题 ([#4469](https://github.com/getpaseo/paseo/pull/4469)) +- 修复了加载期间收到实时更新时缓存的对话和工作区列表无法显示的问题 ([#4436](https://github.com/getpaseo/paseo/pull/4436)) +- 修复了应用在后台时回合完成后排队的消息不发送的问题 ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- 修复了工作区归档后 Codex 重新加载和后续消息因 active-writer 错误而失败的问题 ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- 修复了重复的 Claude 压缩更新留下多余的、一直转圈的 Compacting 行的问题 ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10)) +- 修复了扩展触发的回合之后 Pi Agent 卡在运行中的问题 ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl)) +- 修复了你切换到别处后,延迟完成的工作区创建仍会跳转过去的问题 ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter)) +- 修复了创建工作区后 New workspace 丢失所选模型的问题 ([#4401](https://github.com/getpaseo/paseo/pull/4401)) +- 修复了创建工作区期间所选 Provider 消失导致崩溃的问题 ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- 修复了模型选择器中 Codex 模型行一直显示 Loading 的问题 ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了嵌套的子 Agent 显示在根 Agent 下而不是其实际父 Agent 下的问题 ([#4321](https://github.com/getpaseo/paseo/pull/4321)) +- 修复了自主回合之间重复的工具调用 ID 产生重复时间线行的问题 ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) +- 修复了某个 Provider 挂起时 Import session 一直加载的问题 ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- 修复了 Import session 忽略 Claude Code `/rename` 标题的问题 ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- 修复了导入会话会为已打开的目录创建重复工作区的问题 ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- 修复了空的 ACP 会话出现在 Import session 中的问题 ([#4335](https://github.com/getpaseo/paseo/pull/4335)) +- 修复了恢复的终端标题在加载前短暂显示 Terminal 的问题 ([#4553](https://github.com/getpaseo/paseo/pull/4553)) +- 修复了 Web 和桌面端滚动时 diff 文件标题滞后的问题 ([#4240](https://github.com/getpaseo/paseo/pull/4240)) +- 修复了在 Committed 和 Uncommitted 之间切换时 Changes 与其 diff 不同步的问题 ([#4199](https://github.com/getpaseo/paseo/pull/4199)) +- 修复了 Mermaid 图表在流式输出期间缩小或在重新加载后损坏的问题 ([#4210](https://github.com/getpaseo/paseo/pull/4210)) +- 修复了桌面端 Settings 导航在内存中保留已关闭视图的问题 ([#4228](https://github.com/getpaseo/paseo/pull/4228)) +- 修复了 Windows 上文件监视器漏掉嵌套目录变化的问题 ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- 修复了 daemon 的 Git 命令允许仓库的 `core.fsmonitor` 执行其自定义命令的问题 ([#4208](https://github.com/getpaseo/paseo/pull/4208)) +- 修复了 OpenCode 2 会话的终端活动报告 ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) +- 修复了网站在加载完成前提供错误平台下载的问题 ([#4222](https://github.com/getpaseo/paseo/pull/4222)) + +## 0.7.2 - 2026-09-02 + +### 新增 + +- pi Agent 新增回合中引导,新消息可以到达正在进行的回合而不会中断它 ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger)) +- 托管在 Git 上的插件新增声明式构建命令和 monorepo 源路径 ([#4158](https://github.com/getpaseo/paseo/pull/4158)) + +### 改进 + +- 减少了在移动端观看流式输出的 Agent 时的 JS 卡顿 ([#4190](https://github.com/getpaseo/paseo/pull/4190)) + +### 修复 + +- 修复了打开和滚动大型或包含大量文件的 diff 时卡顿或崩溃的问题 ([#4174](https://github.com/getpaseo/paseo/pull/4174)) +- 修复了 JS 卡顿跨越动画时移动端侧边栏和 Explorer 面板失去停靠位置的问题 ([#4190](https://github.com/getpaseo/paseo/pull/4190)) +- 把渲染内容限制在 32,000 个字符,修复了单条超大助手消息导致时间线崩溃的问题 ([#4166](https://github.com/getpaseo/paseo/pull/4166)) +- 修复了 Codex 0.151 及更高版本上分页线程的 Codex 回退失败的问题 ([#4119](https://github.com/getpaseo/paseo/pull/4119)) +- 修复了按 Escape 关闭图片预览时同时中断正在运行的 Agent 的问题 ([#4161](https://github.com/getpaseo/paseo/pull/4161)) +- 修复了 Windows 上 daemon 停止和重启跳过优雅关闭并遗留孤立 Agent 进程的问题 ([#4168](https://github.com/getpaseo/paseo/pull/4168)) +- 修复了 OMP 启动无论配置的 RPC 超时是多少都在 10 秒时超时的问题 ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g)) +- 修复了没有可见提示词的已完成回合不显示完成时间的问题 ([#4170](https://github.com/getpaseo/paseo/pull/4170)) +- 修复了文件标识元数据不可用时 Explorer 重命名会替换已存在文件的问题 ([#4171](https://github.com/getpaseo/paseo/pull/4171)) + +## 0.7.1 - 2026-09-01 + +### 新增 + +- 新增支持 1M 上下文窗口的 Claude Fable 5.1 ([#4179](https://github.com/getpaseo/paseo/pull/4179)) + +## 0.7.0 - 2026-08-31 + +### 变更 + +- 项目许可证改为 Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944)) +- Cmd/Ctrl+E 改为切换 Explorer 的可见性,而不选择视图 ([#3896](https://github.com/getpaseo/paseo/pull/3896)) +- 移动端默认内容文字大小从 15px 改为 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111)) + +### 新增 + +- 新增直接从 Git 仓库安装和更新插件 ([#3920](https://github.com/getpaseo/paseo/pull/3920)) +- 新增由插件定义的时间线转换和渲染 ([#3940](https://github.com/getpaseo/paseo/pull/3940)) +- 新增从桌面端和 CLI 通过 SSH 连接到已有的远程 daemon ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque)) +- Command Center 新增工作区管理和布局操作 ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter)) +- 新增插件的上下文相关 Composer 胶囊贡献 ([#3956](https://github.com/getpaseo/paseo/pull/3956)) +- 新增面向客户端插件的宿主 UI 基础组件 ([#3967](https://github.com/getpaseo/paseo/pull/3967)) +- Paseo 工具调用新增可读的提示词、详情和结果 ([#4066](https://github.com/getpaseo/paseo/pull/4066)) +- 工作区图片和助手时间线预览新增缩放和平移 ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064)) +- 新增直接在首选的桌面编辑器中打开子文件夹 ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky)) +- 新增从插件界面导航到 Agent 和工作区 ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet)) +- 插件客户端界面新增由宿主渲染的图标 ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet)) +- 插件主题新增语义化的表面、边框、成功和警告颜色 ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet)) +- 公开的插件 SDK 新增已注册项目列表 ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet)) +- Command Center 新增按 PR 和 MR 编号搜索 ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter)) +- Agent SDK 句柄新增会话命令 ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio)) +- 新增 F-Droid 发布元数据和稳定版更新日志生成 ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm)) +- 文件、编辑器和 diff 新增 Astro 语法高亮 ([#3997](https://github.com/getpaseo/paseo/pull/3997)) + +### 改进 + +- 助手文本以稳定的视觉速率流式显示,而不是按到达时的块显示 ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2)) +- 在恢复无关缓存和网络加载之前先绘制缓存的对话 ([#3907](https://github.com/getpaseo/paseo/pull/3907)) +- 普通 Agent 会话复用同一个 OpenCode 辅助进程,而不是每个 Agent 启动一个 ([#4009](https://github.com/getpaseo/paseo/pull/4009)) +- 在预留的 API 配额内批量轮询 GitHub 拉取请求 ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai)) +- GitLab 和 Gitea 检查新增手动、需要操作和警告状态 ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx)) +- 冷启动时的拉取请求状态检查继续使用共享的 GitHub 批量路径 ([#4025](https://github.com/getpaseo/paseo/pull/4025)) + +### 修复 + +- 修复了 Claude 或 OMP 的 JSONL 进程在写入期间关闭导致 daemon 崩溃的问题 ([#4048](https://github.com/getpaseo/paseo/pull/4048)) +- 修复了 Composer 销毁期间键盘重启导致 Android 首条消息提交崩溃的问题 ([#4044](https://github.com/getpaseo/paseo/pull/4044)) +- 修复了移动端长草稿把 Composer 控件挡在键盘后面的问题 ([#4051](https://github.com/getpaseo/paseo/pull/4051)) +- 修复了点击可选择文本时 Android 时间线发生偏移的问题 ([#4090](https://github.com/getpaseo/paseo/pull/4090)) +- 修复了 Web 和 Electron 的 Composer 在提交后失去焦点的问题 ([#4067](https://github.com/getpaseo/paseo/pull/4067)) +- 修复了 Android 语音输入在采集停止后让蓝牙音频保持通话质量路由的问题 ([#4069](https://github.com/getpaseo/paseo/pull/4069)) +- 修复了放弃部分转写后语音输入重试超时的问题 ([#4065](https://github.com/getpaseo/paseo/pull/4065)) +- 修复了立即提交语音输入时遗漏最新语音片段的问题 ([#3968](https://github.com/getpaseo/paseo/pull/3968)) +- 修复了 iPad 桌面布局中模型选择器崩溃的问题 ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim)) +- 修复了 Provider 子 Agent 时间线中缺少 OpenCode 子会话提示词的问题 ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger)) +- 修复了从 History 打开已归档的 Agent 时 Agent 消失的问题 ([#4033](https://github.com/getpaseo/paseo/pull/4033)) +- 修复了恢复的工作区标签页陷入协调循环的问题 ([#3987](https://github.com/getpaseo/paseo/pull/3987)) +- 修复了已归档的工作区从持久缓存中重新出现的问题 ([#3975](https://github.com/getpaseo/paseo/pull/3975)) +- 修复了 Grok 统一计费账号显示零额度而不是每周用量的问题 ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude)) +- 修复了 Git 进程排队时 daemon 重新连接停滞的问题 ([#3945](https://github.com/getpaseo/paseo/pull/3945)) +- 修复了较慢的 Pi 和 OMP 启动请求在 30 秒后超时的问题 ([#4008](https://github.com/getpaseo/paseo/pull/4008)) +- 修复了提交和 squash 合并绕过配置的 Git 签名的问题 ([#3976](https://github.com/getpaseo/paseo/pull/3976)) +- 修复了 Android 系统导航栏遮挡 Commits 控件的问题 ([#4005](https://github.com/getpaseo/paseo/pull/4005)) +- 修复了新的 Agent 工作显示为重新打开的任务的问题 ([#4068](https://github.com/getpaseo/paseo/pull/4068)) +- 修复了 Markdown 中的字面字符被替换为排版符号的问题 ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter)) +- 修复了回退长对话时重放整个重建的时间线的问题 ([#3642](https://github.com/getpaseo/paseo/pull/3642)) +- 修复了回退的提示词在已挂载的 iOS Composer 中仍为空白的问题 ([#3946](https://github.com/getpaseo/paseo/pull/3946)) +- 修复了 iOS 上原生插件设置和异步客户端回调失败的问题 ([#3942](https://github.com/getpaseo/paseo/pull/3942)) +- 修复了 ACP Agent 把终端能力报告为不可用的问题 ([#3910](https://github.com/getpaseo/paseo/pull/3910) by [@pmilanez](https://github.com/pmilanez)) +- 修复了已取消的 Claude 权限请求留下过时权限卡片的问题 ([#3792](https://github.com/getpaseo/paseo/pull/3792)) +- 修复了插件 daemon 会话隐藏非旧版 Provider 的 Agent 的问题 ([#3902](https://github.com/getpaseo/paseo/pull/3902) by [@omercnet](https://github.com/omercnet)) +- 修复了手动允许的桌面端更新在就绪检查期间消失的问题 ([#3865](https://github.com/getpaseo/paseo/pull/3865)) +- 修复了移动端 Changes 和拉取请求操作在 Explorer 之外打开的问题 ([#3867](https://github.com/getpaseo/paseo/pull/3867)) +- 修复了旧版桌面构建删除新版构建写入的设置的问题 ([#3909](https://github.com/getpaseo/paseo/pull/3909) by [@cleiter](https://github.com/cleiter)) +- 修复了从 Settings 返回后 Composer 高度闪烁的问题 ([#3943](https://github.com/getpaseo/paseo/pull/3943)) +- 修复了 Light 主题中所选工作区没有可见高亮的问题 ([#3922](https://github.com/getpaseo/paseo/pull/3922) by [@wdaubney](https://github.com/wdaubney)) +- 修复了滚动 diff 的顺序与 Changes 树不一致的问题 ([#3913](https://github.com/getpaseo/paseo/pull/3913) by [@cleiter](https://github.com/cleiter)) +- 修复了 Close 操作的西班牙语翻译错误 ([#3934](https://github.com/getpaseo/paseo/pull/3934) by [@antonio](https://github.com/antonio)) + +## 0.6.1 - 2026-08-25 + +### 改进 + +- Command Center 现在可以以任意顺序匹配部分多词查询,并按相关性对可见结果排序 ([#2971](https://github.com/getpaseo/paseo/pull/2971) by [@cleiter](https://github.com/cleiter)) + +### 修复 + +- 修复了升级后持久化的 Side panel 标签页被放进 Explorer 而不是普通侧窗格的问题 ([#3861](https://github.com/getpaseo/paseo/pull/3861)) +- 修复了桌面端和 Web 上按 Escape 后 Settings 仍保持打开的问题 ([#2828](https://github.com/getpaseo/paseo/pull/2828) by [@mcowger](https://github.com/mcowger)) +- 修复了切换分支会隐藏当前拉取请求,或因已合并的拉取请求而触发自动归档的问题 ([#3799](https://github.com/getpaseo/paseo/pull/3799)) + +## 0.6.0 - 2026-08-25 + +### 变更 + +- 恢复了专用的 Explorer 侧边栏,默认包含 Files 和 Changes,取代 0.5.0 中引入的、由用户自行安排的空 Side panel ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- 在侧边打开的内容现在使用普通的、可关闭的工作区窗格,而不是 Explorer 侧边栏 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- 恢复了平板上可调整大小的组合式 Explorer 停靠栏 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) + +### 新增 + +- 新增按操作设置在主面板或侧边打开文件、更改、子 Agent 和拉取请求 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- Explorer 中新增 New tab,并支持拖动兼容的文件、diff、Agent、终端、拉取请求和插件面板 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- 新增独立的 Diff 标签页,同时在 Changes 中保留可选且会持久保存的内联 diff 模式 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- 新增 Windows 和 Linux 上的自定义窗口控件 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) + +### 修复 + +- 修复了启动期间第一个事件流连接停滞时 OpenCode 回合失败的问题 ([#3814](https://github.com/getpaseo/paseo/pull/3814)) +- 修复了 Provider 报告连接就绪之前 OpenCode 提示词就开始执行的问题 ([#3821](https://github.com/getpaseo/paseo/pull/3821)) + +## 0.5.2 - 2026-08-24 + +### 修复 + +- 修复了 Android 平板和折叠屏在非紧凑布局下工作区崩溃的问题 ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64)) +- 修复了共享同一配置的不同构建识别的设置不同时应用设置被重置的问题 ([#3787](https://github.com/getpaseo/paseo/pull/3787)) + +## 0.5.1 - 2026-08-23 + +### 修复 + +- 修复了 iOS 和 Android 上多行 Composer 在工作区和 Agent 切换后不再调整大小的问题 ([#3740](https://github.com/getpaseo/paseo/pull/3740)) + +## 0.5.0 - 2026-08-23 + +### 新增 + +- 新增实验性的受信任本地插件 ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465)) +- Settings → Appearance 新增由插件贡献的应用主题 ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan)) +- 新增由用户自行安排的 Side panel,带有独立的标签页 ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605)) +- 新增浏览器风格的 New 标签页,带有锚定在窗格内的选择器 ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735)) +- Claude、Codex 和 OpenCode 新增回合中引导 ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger)) +- 新增用于侧边栏组织和过滤的工作区标签 ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter)) +- 在活动 Agent 的 Composer 上方新增实时的工作区更改计数 ([#3682](https://github.com/getpaseo/paseo/pull/3682)) +- Command Center 新增工作区面板、标签页和窗格操作 ([#3685](https://github.com/getpaseo/paseo/pull/3685)) +- 新增 `paseo project`,可在终端中创建、列出、重命名和删除项目 ([#3460](https://github.com/getpaseo/paseo/pull/3460)) +- 新增 `paseo reload`,无需重启 daemon 即可应用可在运行时安全生效的 `config.json` 更改 ([#3365](https://github.com/getpaseo/paseo/pull/3365)) +- Host → Agents 新增 Paseo 技能管理,包括远程主机 ([#3451](https://github.com/getpaseo/paseo/pull/3451)) +- 侧边栏显示菜单新增按项目过滤 ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter)) +- 工作区行新增可选显示的分支和项目名称 ([#3445](https://github.com/getpaseo/paseo/pull/3445)) +- 新增拖放调整已固定工作区的顺序 ([#3341](https://github.com/getpaseo/paseo/pull/3341)) +- 新增从模型选择器一键创建 Agent 配置 ([#3533](https://github.com/getpaseo/paseo/pull/3533)) +- Open in editor 新增 Android Studio ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky)) +- 一键 ACP Provider 目录新增 MiniMax Code ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend)) +- 新增 Nix 语法高亮 ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy)) +- 新增 Svelte 语法高亮 ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo)) +- 新增引导式 Hub 设置,可连接 daemon 并部署一个兼容的入门 workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677)) + +### 改进 + +- 主机重新连接期间立即恢复缓存的项目、工作区、Agent 和时间线 ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329)) +- 减少了桌面端和 Android 上长聊天时切换工作区的卡顿 ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610)) +- Web 和桌面端的 Composer 输入保持在帧预算之内 ([#3450](https://github.com/getpaseo/paseo/pull/3450)) +- 大型 diff 在展开、滚动和评论时保持响应 ([#3422](https://github.com/getpaseo/paseo/pull/3422)) +- 通过有界渲染使大型只读源码预览保持响应 ([#3665](https://github.com/getpaseo/paseo/pull/3665)) +- 长时间运行的 Agent 时间线不再完整重写对话记录到磁盘 ([#3647](https://github.com/getpaseo/paseo/pull/3647)) +- 拉取请求检查新增结果摘要,并优先分组显示失败项 ([#3483](https://github.com/getpaseo/paseo/pull/3483)) +- 子 Agent 和任务跟踪器移到了 Composer 上方的胶囊中 ([#3482](https://github.com/getpaseo/paseo/pull/3482)) +- 移动端 Agent 配置简化为一个选项面板 ([#3424](https://github.com/getpaseo/paseo/pull/3424)) +- 在紧凑布局中,汇总的工具调用组改为在实时更新的底部面板中打开 ([#3619](https://github.com/getpaseo/paseo/pull/3619)) +- 为聊天、Composer、Markdown 和审查文本新增单独的 Content 文字大小设置 ([#3637](https://github.com/getpaseo/paseo/pull/3637)) +- Pi 的用量和上下文计量在回合进行中更新 ([#3532](https://github.com/getpaseo/paseo/pull/3532)) +- Archive finished 现在会归档栏中所有已完成的子 Agent ([#3368](https://github.com/getpaseo/paseo/pull/3368)) +- 自动的 Setup 标签页现在只在工作区设置失败后出现 ([#3634](https://github.com/getpaseo/paseo/pull/3634)) +- Settings 中的 Default send 改为下拉菜单 ([#3644](https://github.com/getpaseo/paseo/pull/3644)) +- 桌面应用和 CLI 中的配对链接新增“等同于密码”的警告 ([#3734](https://github.com/getpaseo/paseo/pull/3734)) +- 修正了俄语界面翻译和术语 ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz)) + +### 修复 + +- 修复了文本框和移动端终端中 CJK 输入法组字被取消的问题 ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev)) +- 修复了事件流断开时 OpenCode 回合失败的问题 ([#3395](https://github.com/getpaseo/paseo/pull/3395)) +- 修复了启动较慢或加载大量插件时 OpenCode Agent 超时的问题 ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco)) +- 修复了不相关的已合并拉取请求导致工作区被归档的问题 ([#3425](https://github.com/getpaseo/paseo/pull/3425)) +- 修复了在已加载的桌面浏览器页面上 Annotate element 和 Screenshot element 没有反应的问题 ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev)) +- 修复了 heartbeat 运行后重复出现复制和 fork 页脚的问题 ([#3484](https://github.com/getpaseo/paseo/pull/3484)) +- 修复了只通过 `cursor-agent` 登录的主机上 Cursor 套餐用量的问题 ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G)) +- 修复了移动端 Composer 在提交后仍保留已发送文本的问题 ([#3564](https://github.com/getpaseo/paseo/pull/3564)) +- 修复了多行 Composer 在工作区和草稿的生命周期之间保留过时高度的问题 ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740)) +- 修复了目标仓库被配置为 `upstream` 时检出拉取请求的问题 ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger)) +- 修复了 Claude 或 Codex 等待权限时 Composer 的引导消息一直未被读取的问题 ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter)) +- 修复了检出状态变化后 Changes 打开空比较的问题 ([#3636](https://github.com/getpaseo/paseo/pull/3636)) +- 修复了自动重试成功期间 Pi 聊天以错误状态结束的问题 ([#3639](https://github.com/getpaseo/paseo/pull/3639)) +- 修复了 macOS 钥匙串中有多个凭据项时 Claude 用量显示为不可用的问题 ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit)) +- 修复了 Provider 用量检查覆盖 Claude 或 Codex 凭据文件的问题 ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei)) +- 修复了原生线程在 Paseo 之外被归档后活动的 Codex Agent 无法打开的问题 ([#3334](https://github.com/getpaseo/paseo/pull/3334)) +- 修复了 Provider 已经结束回合后 Agent 仍然无法停止的问题 ([#3742](https://github.com/getpaseo/paseo/pull/3742)) +- 修复了创建 worktree 时拒绝包含大写字母、下划线或点的合法 Git 分支名的问题 ([#3591](https://github.com/getpaseo/paseo/pull/3591)) +- 修复了 New Workspace 中项目图标显示过时图片的问题 ([#3600](https://github.com/getpaseo/paseo/pull/3600)) +- 修复了格式错误的 SVG 项目图标导致 iOS 应用崩溃的问题 ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet)) +- 修复了固定项目唯一的工作区后其 New workspace 操作被隐藏的问题 ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923)) +- 修复了工作区布局变化绕过时间线重试退避的问题 ([#3736](https://github.com/getpaseo/paseo/pull/3736)) +- 修复了全新安装解析到未经该版本验证的 Provider SDK 版本的问题 ([#3678](https://github.com/getpaseo/paseo/pull/3678)) + +## 0.4.0 - 2026-08-13 + +### 破坏性变更 + +- 移除了 `paseo chat` 和 `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053)) + +### 新增 + +- 新增主机级的 Agent 配置,可复用 Provider、模型、模式、思考和功能设置 ([#3208](https://github.com/getpaseo/paseo/pull/3208)) +- Cmd/Ctrl+P 新增工作区文件搜索 ([#3059](https://github.com/getpaseo/paseo/pull/3059)) +- Files 和 Changes 新增文件和文件夹操作 ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs)) +- 聊天和 Markdown 预览新增可交互的 Mermaid 图表 ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon)) +- 在 Composer 上方和时间线中新增实时任务进度 ([#3227](https://github.com/getpaseo/paseo/pull/3227)) +- 新增用于生成工作区标题、分支名、提交和拉取请求草稿的模型设置 ([#3215](https://github.com/getpaseo/paseo/pull/3215)) +- 新增受支持的 `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141)) +- CLI 新增 Provider 安装诊断 ([#3243](https://github.com/getpaseo/paseo/pull/3243)) +- CLI 新增工作区重命名 ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik)) + +### 改进 + +- Markdown 预览新增居中的阅读布局和格式化的 YAML front matter ([#3240](https://github.com/getpaseo/paseo/pull/3240)) +- Command Center 新增侧边栏分组 ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter)) +- 主题快捷键现在包含所有内置主题 ([#3214](https://github.com/getpaseo/paseo/pull/3214)) +- 移除了空闲工作区的定期 Git 轮询 ([#3323](https://github.com/getpaseo/paseo/pull/3323)) +- Provider 目录刷新设置为可配置的两分钟期限 ([#3322](https://github.com/getpaseo/paseo/pull/3322)) + +### 修复 + +- 修复了主机睡眠或 daemon worker 停滞后终端会话丢失的问题 ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263)) +- 修复了归档工作区时 daemon 挂起的问题 ([#3107](https://github.com/getpaseo/paseo/pull/3107)) +- 修复了项目分布在多台主机上时 Android 上 New Workspace 崩溃的问题 ([#3241](https://github.com/getpaseo/paseo/pull/3241)) +- 修复了 Provider 缓存耗尽 Android 本地存储的问题 ([#3234](https://github.com/getpaseo/paseo/pull/3234)) +- 修复了连接中断期间语音输入录音丢失的问题 ([#3159](https://github.com/getpaseo/paseo/pull/3159)) +- 修复了新工作区复用已有 worktree 的问题 ([#3224](https://github.com/getpaseo/paseo/pull/3224)) +- 修复了新 worktree 忽略 Project Settings 中保存的设置命令的问题 ([#3233](https://github.com/getpaseo/paseo/pull/3233)) +- 修复了所选基础分支包含不同的 `paseo.json` 时新 worktree 变脏的问题 ([#3311](https://github.com/getpaseo/paseo/pull/3311)) +- 修复了已移除的主机仍继续收到推送通知的问题 ([#3176](https://github.com/getpaseo/paseo/pull/3176)) +- 修复了权限提示或工作区关闭后委派 Agent 的通知停止的问题 ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456)) +- 修复了模型目录超过 protocol-v1 帧大小限制时 OMP Agent 无法启动的问题 ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123)) +- 修复了 OpenCode 模型无法回到默认变体的问题 ([#3281](https://github.com/getpaseo/paseo/pull/3281)) +- 修复了已完成的 Codex 子 Agent 仍被标记为活动的问题 ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy)) +- 修复了 Codex 压缩后回合仍被标记为工作中的问题 ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj)) +- 修复了使用当前 Cursor 登录时的 Cursor 用量 ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish)) +- 修复了 SVG 项目图标在移动端渲染为空白的问题 ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了持久化缓存不兼容时崩溃的问题 ([#3289](https://github.com/getpaseo/paseo/pull/3289)) +- 修复了打开的委派 Agent 随其父 Agent 一起被归档的问题 ([#3279](https://github.com/getpaseo/paseo/pull/3279)) +- 修复了配置包含所选 Provider 不支持的模式时创建 Agent 的问题 ([#3331](https://github.com/getpaseo/paseo/pull/3331)) +- 修复了 Claude Agent 需要等待 Paseo 十分钟 MCP 超时的问题 ([#3315](https://github.com/getpaseo/paseo/pull/3315)) +- 修复了 Hermes Agent 的 Copy resume command ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai)) + +## 0.3.1 - 2026-08-09 + +### 新增 + +- 可以取消分配键盘快捷键,并在快捷键帮助中查看实际生效的绑定 ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter)) +- 可以选择 Pure black 主题 ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf)) + +### 改进 + +- 正在运行的 Agent 使用更清晰的动画状态圆环 ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter)) +- 在文件监视器失败、Git 活动繁重以及大型共享 worktree 仓库中,工作区 Git 更新保持响应 ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078)) +- Kimi 用量可以正确刷新,并显示五小时限额和推理级别 ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter)) +- 可以用触摸调整桌面侧边栏的大小 ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### 修复 + +- macOS 上的局域网直接连接恢复正常 ([#3071](https://github.com/getpaseo/paseo/pull/3071)) +- 桌面端更新检查在各平台下载仍在发布时不再报告错误 ([#3076](https://github.com/getpaseo/paseo/pull/3076)) +- 复制的列表跨越选区边界时保留其标记 ([#3055](https://github.com/getpaseo/paseo/pull/3055)) +- 切换主机时保持选中当前活动的项目 ([#3051](https://github.com/getpaseo/paseo/pull/3051)) +- New Workspace 把项目和隔离控件保持在 Composer 内 ([#3050](https://github.com/getpaseo/paseo/pull/3050)) +- 可以从移动端会话菜单重命名会话 ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi)) +- 桌面通知声音恢复播放 ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp)) +- Codex 斜杠命令不再包含已禁用的技能 ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd)) +- 精确的被忽略文件路径可以正确解析 ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter)) +- Claude 问题通知会概括所请求的输入 ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon)) +- 崩溃界面保持可读,并且重试操作始终可用 ([#3043](https://github.com/getpaseo/paseo/pull/3043)) + +## 0.3.0 - 2026-08-08 + +### 新增 + +- 全新的移动端终端,支持文本选择、复制、粘贴和更快的渲染 ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830)) +- 重新设计的侧边栏,状态颜色更清晰,各行更易于浏览 ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711)) + - 可以选择侧边栏工作区行显示的内容:主机、拉取请求、检查和脚本 + - 可以为每台主机命名并设置颜色 ([#2790](https://github.com/getpaseo/paseo/pull/2790)) +- 可以按工作区、Agent 和分支搜索历史记录 ([#2995](https://github.com/getpaseo/paseo/pull/2995)) +- 支持韩语界面 ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos)) +- 可以从 Command Center 执行 git 和工作区操作 ([#2749](https://github.com/getpaseo/paseo/pull/2749)) +- 可以从 Command Center 更改模型、推理、模式、计划和 fast ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu)) +- 可以在 Agent 运行时 fork 它 ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi)) +- 可以直接从 New Workspace 启动终端 ([#2941](https://github.com/getpaseo/paseo/pull/2941)) +- 可以在聊天中的提示词之间跳转 ([#2792](https://github.com/getpaseo/paseo/pull/2792)) +- 可以预览 HTML 文件 ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch)) +- 可以在子 Agent 栏中查看 Claude workflow ([#2933](https://github.com/getpaseo/paseo/pull/2933)) +- 可以选择 Paseo 安装哪些编排技能 ([#2680](https://github.com/getpaseo/paseo/pull/2680)) +- 可以用一个设置批准所有 ACP 工具调用 ([#2752](https://github.com/getpaseo/paseo/pull/2752)) +- 可以为直接主机连接添加自定义 HTTP 头 ([#2922](https://github.com/getpaseo/paseo/pull/2922)) +- 可以选择本地分支或其对应的 origin 分支作为新 worktree 的基础 ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger)) +- 可以在移动端从剪贴板粘贴图片 ([#2793](https://github.com/getpaseo/paseo/pull/2793)) + +### 改进 + +- Provider 数据缓存在客户端中,启动更快 +- 最近的聊天在后台保持实时,切换回去时立即显示最新消息 ([#2842](https://github.com/getpaseo/paseo/pull/2842)) +- 从 Settings 返回工作区更快 ([#2791](https://github.com/getpaseo/paseo/pull/2791)) +- 大型仓库中的工作区 Git 状态保持响应 ([#2979](https://github.com/getpaseo/paseo/pull/2979)) +- 在繁忙的机器上 Paseo 运行更少的 git 进程 ([#2797](https://github.com/getpaseo/paseo/pull/2797)) +- 复制助手回复的一部分时保留其列表、链接和格式 ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter)) +- 可以查看原生 Claude 子 Agent 的完整对话 ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760)) +- OpenCode 子 Agent 显示其任务、类型、模型和 token 用量 ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco)) +- Pi 委派的任务显示其生命周期状态 ([#2891](https://github.com/getpaseo/paseo/pull/2891)) +- Claude 会为新工作区记住模型和思考选择 ([#2912](https://github.com/getpaseo/paseo/pull/2912)) +- OMP 上下文用量在回合进行中更新 ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava)) +- OpenCode 根据自身的忙碌状态报告后台活动 ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn)) +- 桌面浏览器标签页在焦点变化和自动化过程中保持状态 ([#2907](https://github.com/getpaseo/paseo/pull/2907)) +- 新 worktree 从跟踪的上游分支开始 ([#2848](https://github.com/getpaseo/paseo/pull/2848)) +- 生成的工作区标题会描述任务 ([#2755](https://github.com/getpaseo/paseo/pull/2755)) +- 配对设备时,中继访问需要手动开启 ([#2706](https://github.com/getpaseo/paseo/pull/2706)) +- 录音和播放停止时,语音模式会释放音频会话 ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi)) +- 工作区建议会跳过被 Git 忽略的数据 ([#2902](https://github.com/getpaseo/paseo/pull/2902)) +- 重新连接消息会区分 daemon 重启和网络中断 ([#2931](https://github.com/getpaseo/paseo/pull/2931)) +- 文件变化或被删除时,文件查看器显示一个准确的状态 ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694)) +- Nix 桌面包更小 ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Nix 桌面应用在 Linux 和 macOS 上使用正确的图标 ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 可以在 macOS 上通过 Nix flake 构建 Paseo Desktop ([#2556](https://github.com/getpaseo/paseo/pull/2556) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### 修复 + +- Pi 0.84 Agent 不再在每条提示词上陷入崩溃循环 ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa)) +- 工作区文件监视不再导致 daemon 停滞 ([#2858](https://github.com/getpaseo/paseo/pull/2858)) +- 重新连接或恢复后消息不再重复或乱序到达 ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718)) +- 在所有桌面操作系统上,Git 状态和 diff 都能识别嵌套文件夹中的变化 ([#2775](https://github.com/getpaseo/paseo/pull/2775)) +- Claude 运行时失败会报告错误,而不是让工作区停留在空闲状态 ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- Claude 重放不再留下过时的运行中子 Agent ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter)) +- Claude Opus 5 会显示 Fast 开关 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter)) +- Provider 进程退出时 Agent 会以错误停止 ([#2757](https://github.com/getpaseo/paseo/pull/2757)) +- 取消 OpenCode 回合不再破坏下一个回合 ([#2662](https://github.com/getpaseo/paseo/pull/2662)) +- ACP 权限提示不再在回合中途消失 ([#2762](https://github.com/getpaseo/paseo/pull/2762)) +- 原生子 Agent 运行时,工作区不再显示为空闲 ([#2777](https://github.com/getpaseo/paseo/pull/2777)) +- 回合被中断后终端活动会停止 ([#2942](https://github.com/getpaseo/paseo/pull/2942)) +- Windows 终端处理延迟的启动失败时不再崩溃 +- 重新创建的工作区文件夹不再被错误地保持为已归档 ([#2987](https://github.com/getpaseo/paseo/pull/2987)) +- 恢复已合并的工作区后不再处于非活动状态 ([#2714](https://github.com/getpaseo/paseo/pull/2714)) +- 切换工作区不再让聊天滚离你正在阅读的位置 ([#2838](https://github.com/getpaseo/paseo/pull/2838)) +- 对话重新加载时图片保持在原位 +- 移动端侧边栏中的滑动和拖动手势可以正常使用 ([#2709](https://github.com/getpaseo/paseo/pull/2709)) +- 语音输入的提示词在提交时不再消失 ([#2745](https://github.com/getpaseo/paseo/pull/2745)) +- Android 上 Composer 保持在键盘上方 +- 在移动端使用输入法输入时不再丢失组字文本 +- 切换标签页时 Composer 工具栏不再闪烁 ([#2937](https://github.com/getpaseo/paseo/pull/2937)) +- 切换主机时保持选中你所选的项目 ([#2700](https://github.com/getpaseo/paseo/pull/2700)) +- 主机连接选项在 Settings 和桌面端刷新后保持选中 ([#2905](https://github.com/getpaseo/paseo/pull/2905)) +- 切换分支后拉取请求面板会更新 ([#2699](https://github.com/getpaseo/paseo/pull/2699)) +- New Workspace 会立即显示其隔离控件 ([#2702](https://github.com/getpaseo/paseo/pull/2702)) +- 侧边栏快捷键会退出专注模式 ([#2717](https://github.com/getpaseo/paseo/pull/2717)) +- 由 NixOS 服务启动的 Agent 不再以生产模式运行 ([#2697](https://github.com/getpaseo/paseo/pull/2697) by [@shin-sakata](https://github.com/shin-sakata)) + +## 0.2.5 - 2026-07-30 + +### 修复 + +- 修复了 Linux Debian 包 ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc)) + +## 0.2.4 - 2026-07-30 + +### 新增 + +- 可以从 CLI 设置 Agent 的思考级别 ([#2533](https://github.com/getpaseo/paseo/pull/2533)) +- 创建工作区时可以用 ⌘P / Ctrl+P 切换项目 ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev)) +- 可以从侧边栏打开项目或工作区文件夹 ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030)) +- 在聊天上向上轻扫即可收起键盘 ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx)) +- 自定义 Provider 可以读取 Agent 的工作目录 ([#2563](https://github.com/getpaseo/paseo/pull/2563)) + +### 修复 + +- 在多台机器上添加的同一个仓库在侧边栏中显示为一个项目 ([#2565](https://github.com/getpaseo/paseo/pull/2565)) +- 空闲的 Agent 会保留其后台工作,而不是在未使用时关闭 ([#2590](https://github.com/getpaseo/paseo/pull/2590)) +- 桌面端在恢复文件树时不再于启动时崩溃 ([#2595](https://github.com/getpaseo/paseo/pull/2595)) +- 计划审批只显示最新的提案 ([#2534](https://github.com/getpaseo/paseo/pull/2534)) +- Opus 5 在模型列表中只出现一次,并带有完整的 1M 上下文 ([#2497](https://github.com/getpaseo/paseo/pull/2497)) +- 上下文计量不再在对话中途变为空白 ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava)) +- 非常大的工作区 diff 不再导致会话断开 ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs)) +- 中断 Pi 不再显示流错误 ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger)) +- Settings 中重新显示 Grok 用量 ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd)) +- 不报告上下文窗口的 OMP 模型现在可以加载 ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky)) +- Paseo 自己的工具可以直接在 OMP 中使用 ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd)) +- Codex 可以找到项目中定义的技能 ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang)) +- 包含 HTML 的拉取请求评论可以正确渲染 ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger)) +- 自托管 forge 链接保留其端口 ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991)) +- 从桌面打开 Linux AppImage 时可以正常启动 ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray)) +- 仓库搜索可以在较旧版本的 GitHub CLI 上使用 ([#2611](https://github.com/getpaseo/paseo/pull/2611)) + +## 0.2.3 - 2026-07-27 + +### 新增 + +- 可以从 CLI 和 Agent MCP 工具管理工作区脚本 ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger)) +- 可以从终端标签页菜单复制终端 ID ([#2371](https://github.com/getpaseo/paseo/pull/2371)) +- 文件编辑器中较长的 Markdown 行默认自动换行 ([#2459](https://github.com/getpaseo/paseo/pull/2459)) + +### 改进 + +- 退出时桌面端会停止其管理的 daemon,除非启用了 “Keep daemon running after quit” ([#2454](https://github.com/getpaseo/paseo/pull/2454)) +- 远程终端和文件流量在加密连接上占用更少带宽 ([#2480](https://github.com/getpaseo/paseo/pull/2480)) +- 工作区搜索现在显示并匹配项目名称 ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter)) +- Claude 用量显示按模型区分的每周限额 ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter)) +- OMP 模型只显示其支持的思考级别 ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid)) + +### 修复 + +- 图片上传保留正确的图片格式 ([#2380](https://github.com/getpaseo/paseo/pull/2380)) +- 查看大文件不再导致会话断开 ([#2482](https://github.com/getpaseo/paseo/pull/2482)) +- 滚动到聊天顶部时会加载完整的较早历史 ([#2481](https://github.com/getpaseo/paseo/pull/2481)) +- 子 Agent 工作期间父 Agent 保持可用 ([#2458](https://github.com/getpaseo/paseo/pull/2458)) +- 过时的客户端连接不再耗尽 daemon 内存 ([#2169](https://github.com/getpaseo/paseo/pull/2169)) +- 侧边栏分区折叠时固定和取消固定快捷键仍然有效 ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter)) +- `Shift+Tab` 不再更改后台 Agent 的权限模式 ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter)) +- 被代理的服务在重定向中保留端口 ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter)) +- Provider 设置可以在模型选择器上方正确打开 ([#2476](https://github.com/getpaseo/paseo/pull/2476)) +- 点击文件编辑器会正确聚焦其窗格 ([#2457](https://github.com/getpaseo/paseo/pull/2457)) + +## 0.2.2 - 2026-07-25 + +### 修复 + +- Claude 5 模型现在使用正确的上下文窗口。 + +## 0.2.1 - 2026-07-24 + +### 新增 + +- 可以使用 Claude Opus 5 + +## 0.2.0 - 2026-07-24 + +### 新增 + +- 可以处理来自 GitLab、Gitea、Forgejo 和 Codeberg 的拉取请求和合并请求 ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx)) +- 可以直接在 Web 和桌面应用中编辑文件 ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang)) +- 新增 Oh My Pi (OMP) 作为原生 Agent Provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223)) +- 可以把完整的 Changes 视图作为工作区标签页打开 ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs)) +- 可以直接从 Files 和 Changes 把文件添加到聊天 ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs)) +- 可以在 Changes 中浏览工作区的提交历史并打开单个提交的 diff ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr)) +- 可以从 Command Center 为活动的 Agent 和新草稿切换模型 ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu)) +- 可以从 Paseo 链接或 CLI 打开已有的 Agent ([#2324](https://github.com/getpaseo/paseo/pull/2324)) +- 可以用固定范围或外部分配器配置工作区服务端口 ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger)) +- 可以按操作、说明或按键组合搜索键盘快捷键 ([#2160](https://github.com/getpaseo/paseo/pull/2160)) +- 可以为支持的 Claude 模型关闭思考 ([#2257](https://github.com/getpaseo/paseo/pull/2257)) +- 支持 Pi 的 Max 思考级别 ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue)) +- 可以在更多已安装的编辑器和文件管理器中打开工作区文件 ([#2119](https://github.com/getpaseo/paseo/pull/2119)) +- 可以从 Settings 中移除单个自定义 Provider ([#1951](https://github.com/getpaseo/paseo/pull/1951)) + +### 改进 + +- 改进了移动端的模型选择 ([#2361](https://github.com/getpaseo/paseo/pull/2361)) +- 选择器弹出框在 iPad 上保持可读 ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim)) +- 项目、工作区和聊天的同步更高效 ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263)) +- CLI 和 MCP 工具管理工作区、Agent 和定时任务的方式更一致 ([#2186](https://github.com/getpaseo/paseo/pull/2186)) +- 粘贴到 Composer 中的 PR/MR 链接会被自动选为检出选项 ([#2290](https://github.com/getpaseo/paseo/pull/2290)) +- 项目创建过程更加明确 ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187)) +- 空闲的 Agent 会自动释放进程,并在需要时恢复 ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209)) +- 在支持的情况下,新的 Claude 和 Codex Agent 默认使用更安全的自动审批模式 ([#2213](https://github.com/getpaseo/paseo/pull/2213)) +- 回合期间进行的权限和思考更改现在会显示何时生效 ([#2201](https://github.com/getpaseo/paseo/pull/2201)) +- 接近 Provider 限额时用量条会发出警告 ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter)) +- 工作区专注模式限定在当前活动的工作区,并带有可见的退出控件 ([#2151](https://github.com/getpaseo/paseo/pull/2151)) +- 桌面端安装最新的可用更新,而不是缓存的旧版本 ([#2149](https://github.com/getpaseo/paseo/pull/2149)) +- 远程 daemon 更新失败时会显示具体的恢复步骤 ([#2120](https://github.com/getpaseo/paseo/pull/2120)) +- Agent 历史错误现在立即显示,而不是在超时之后 ([#2124](https://github.com/getpaseo/paseo/pull/2124)) + +### 修复 + +- 终端配对二维码在较窄的终端中仍可扫描 ([#2381](https://github.com/getpaseo/paseo/pull/2381)) +- 即使有许多活动或已归档的工作区,创建工作区也保持响应 ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379)) +- Agent 启动失败后不再遗留正在运行的 Provider 进程 ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang)) +- 迟到的元数据更新到达时,已完成的 OpenCode 回合保持空闲 ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger)) +- ACP 图片提示词不再出现两次 ([#2363](https://github.com/getpaseo/paseo/pull/2363)) +- 非默认浏览器缩放下,Web 聊天保持固定在最新消息 ([#2368](https://github.com/getpaseo/paseo/pull/2368)) +- 分组的工具调用加载动画正确显示 ([#2369](https://github.com/getpaseo/paseo/pull/2369)) +- 通知现在会打开正确的工作区和 Agent ([#2331](https://github.com/getpaseo/paseo/pull/2331)) +- 可以直接从 History 恢复已归档的 Agent ([#2316](https://github.com/getpaseo/paseo/pull/2316)) +- 除非请求新工作区,CLI 运行的 Agent 保持在当前工作区中 ([#2315](https://github.com/getpaseo/paseo/pull/2315)) +- 复用的分支不再关联不相关的已合并或已关闭的拉取请求 ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx)) +- Pi 压缩会等待较长的摘要,而不是错误地报告超时 ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd)) +- 空闲 Agent 恢复后,Pi 聊天的新消息与正确的历史对齐 ([#2313](https://github.com/getpaseo/paseo/pull/2313)) +- 由已完成的后台工作触发的 OpenCode 后续消息现在保持可见 ([#2258](https://github.com/getpaseo/paseo/pull/2258)) +- Codex 不再把父 Agent 显示为幽灵子 Agent ([#2214](https://github.com/getpaseo/paseo/pull/2214)) +- Oh My Pi 的后台通知显示为任务通知,而不是原始系统文本 ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223)) +- 本地语音输入现在可以在 Nix 打包的安装中使用 ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991)) +- 提交语音输入的文本并返回应用后 Composer 保持可见 ([#2194](https://github.com/getpaseo/paseo/pull/2194)) +- 完成录音后桌面端的语音输入快捷键保持响应 ([#2268](https://github.com/getpaseo/paseo/pull/2268)) +- 可以在创建第一个工作区之前重命名项目 ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago)) +- 本地 daemon 停止时,Settings 仍显示已连接的远程主机 ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang)) +- 重新打开紧凑侧边栏时已固定的工作区不再短暂消失 ([#2210](https://github.com/getpaseo/paseo/pull/2210)) +- 焦点或可见性变化后终端窗格不再停留在 80x24 ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter)) +- 桌面浏览器中的登录弹窗现在可以顺利完成 ([#2137](https://github.com/getpaseo/paseo/pull/2137)) +- 在浏览器中输入和使用快捷键不再提交当前的 Paseo 提示词 ([#1982](https://github.com/getpaseo/paseo/pull/1982)) +- 切换工作区后 Agent 的浏览器标签页仍可控制 ([#2156](https://github.com/getpaseo/paseo/pull/2156)) +- 已归档的工作区现在显示正确的 Unarchive 或 Restore 操作 ([#2002](https://github.com/getpaseo/paseo/pull/2002)) +- 可以把已归档的会话重新导入当前工作区 ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs)) +- 浏览器快捷键不再出现在没有浏览器标签页的地方 ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd)) + +## 0.1.110 - 2026-07-16 + +### 修复 + +- 响应正在流式输出时,Kimi 和其他 ACP Agent 现在保持标记为运行中 ([#2148](https://github.com/getpaseo/paseo/pull/2148)) + +## 0.1.109 - 2026-07-16 + +> **重要更新通知** +> +> 如果你安装的是 Paseo Desktop 0.1.108,需要[手动下载并重新安装 Paseo](https://paseo.sh/download) 才能获得此修复。0.1.108 中的缺陷导致其自动更新程序无法安装 0.1.109。使用 0.1.107 或更早版本的用户可以正常更新。 + +### 修复 + +- Paseo Desktop 更新后不再卡在连接中或丢失原生窗口控件 ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter)) + +## 0.1.108 - 2026-07-16 + +### 新增 + +- 可以从 Add Project 创建新的项目文件夹或克隆 GitHub 仓库 ([#1331](https://github.com/getpaseo/paseo/pull/1331), [#2045](https://github.com/getpaseo/paseo/pull/2045), [#2097](https://github.com/getpaseo/paseo/pull/2097) by [@mcowger](https://github.com/mcowger)) +- 可以从搜索菜单搜索并打开工作区 ([#2096](https://github.com/getpaseo/paseo/pull/2096)) +- 可以把工作区固定在侧边栏顶部 ([#1981](https://github.com/getpaseo/paseo/pull/1981) by [@half144](https://github.com/half144)) +- 通过新的外观设置,可以把工具调用汇总为一个折叠的条目 ([#2031](https://github.com/getpaseo/paseo/pull/2031), [#2069](https://github.com/getpaseo/paseo/pull/2069), [#2090](https://github.com/getpaseo/paseo/pull/2090) by [@mcowger](https://github.com/mcowger)) +- 浏览器 Cookie 和网站数据在标签页之间以及重启后保留 ([#2089](https://github.com/getpaseo/paseo/pull/2089)) +- Claude 和 Codex 子 Agent 现在显示其实际名称,并新增归档已完成的 Claude Code、Codex 和 OpenCode 子 Agent 的选项 ([#2073](https://github.com/getpaseo/paseo/pull/2073)) +- 可以从失败的回合 fork 聊天 ([#2063](https://github.com/getpaseo/paseo/pull/2063)) + +### 改进 + +- 权限模式的图标更清晰 ([#1980](https://github.com/getpaseo/paseo/pull/1980) by [@cleiter](https://github.com/cleiter)) +- 桌面端在较窄的窗口中仍可使用 ([#1983](https://github.com/getpaseo/paseo/pull/1983)) +- 桌面面板打开和关闭时侧边栏控件保持在原位 ([#2078](https://github.com/getpaseo/paseo/pull/2078)) +- 在长草稿中输入更流畅 ([#2086](https://github.com/getpaseo/paseo/pull/2086)) +- Codex 终端命令即使没有输出也始终显示在聊天中 ([#2037](https://github.com/getpaseo/paseo/pull/2037)) + +### 修复 + +- 切换项目或主机时,New Workspace 保留你的提示词和附件 ([#2030](https://github.com/getpaseo/paseo/pull/2030), [#2036](https://github.com/getpaseo/paseo/pull/2036)) +- OpenCode 会话关闭时不再导致 Paseo 崩溃 ([#2027](https://github.com/getpaseo/paseo/pull/2027) by [@mcowger](https://github.com/mcowger)) +- Pi 斜杠命令不再让聊天卡在运行中 ([#2066](https://github.com/getpaseo/paseo/pull/2066) by [@ebg1223](https://github.com/ebg1223)) +- 后台 Agent 的更新现在显示在主回复之后 ([#2058](https://github.com/getpaseo/paseo/pull/2058) by [@1254087415](https://github.com/1254087415)) +- Codex 子 Agent 不再从 Subagents 栏中消失 ([#2068](https://github.com/getpaseo/paseo/pull/2068)) +- fork 的聊天在新标签页中打开后即可编辑 ([#2038](https://github.com/getpaseo/paseo/pull/2038)) +- 关机被中断后 Paseo Desktop 可以正常打开 ([#1962](https://github.com/getpaseo/paseo/pull/1962)) +- 键盘快捷键现在可以使用 `-`、`=`、`;` 和 `'` ([#2047](https://github.com/getpaseo/paseo/pull/2047) by [@OnCloud125252](https://github.com/OnCloud125252)) +- Codebuddy Code 模型现在出现在模型选择器中 ([#1979](https://github.com/getpaseo/paseo/pull/1979) by [@park0er](https://github.com/park0er)) +- 工作区搜索现在包含 OpenCode 命令和 workflow ([#2049](https://github.com/getpaseo/paseo/pull/2049)) +- Nix 安装现在包含 Paseo Web 应用 ([#1978](https://github.com/getpaseo/paseo/pull/1978) by [@liamdiprose](https://github.com/liamdiprose)) + +## 0.1.107 - 2026-07-13 + +### 新增 + +- 可以在 Subagents 栏中查看由 Provider 创建的子 Agent 及其实时对话 ([#2013](https://github.com/getpaseo/paseo/pull/2013) by [@omercnet](https://github.com/omercnet)) +- 所有受支持的 Agent Provider 都可以 fork 聊天 ([#2022](https://github.com/getpaseo/paseo/pull/2022)) + +### 改进 + +- 没有配置项目时,可以直接从 New Workspace 添加项目 ([#2026](https://github.com/getpaseo/paseo/pull/2026)) +- 新终端会立即以正确的尺寸打开 ([#2023](https://github.com/getpaseo/paseo/pull/2023) by [@cleiter](https://github.com/cleiter)) +- 侧边栏底部操作现在带有提示文字 ([#2025](https://github.com/getpaseo/paseo/pull/2025)) +- Codex shell 工具调用只显示正在运行的命令 ([#2029](https://github.com/getpaseo/paseo/pull/2029)) +- 自定义 ACP Provider 默认把文件和终端工作保留在 Agent 环境中 ([#2024](https://github.com/getpaseo/paseo/pull/2024)) +- ACP Provider 目录更新到最新的注册表版本 + +### 修复 + +- 大型表格不再导致 iOS 聊天失去响应 +- 滚动到底部按钮附近的聊天控件仍可点击 ([#2007](https://github.com/getpaseo/paseo/pull/2007)) +- 超大的工具输出不再拖慢或淹没聊天时间线 ([#2020](https://github.com/getpaseo/paseo/pull/2020)) +- 跨 Provider 的子 Agent 可以使用没有模式设置的 Provider ([#2000](https://github.com/getpaseo/paseo/pull/2000) by [@githubbzxs](https://github.com/githubbzxs)) +- Pi 内部的元数据任务不再干扰正常的会话历史 ([#1999](https://github.com/getpaseo/paseo/pull/1999) by [@githubbzxs](https://github.com/githubbzxs)) +- 取消扩展命令后 Pi 聊天仍可使用 ([#2019](https://github.com/getpaseo/paseo/pull/2019)) + +## 0.1.106 - 2026-07-12 + +### 新增 + +- 可以在 Paseo 中批准 Codex MCP 权限请求 ([#2001](https://github.com/getpaseo/paseo/pull/2001)) + +### 改进 + +- ACP Provider 目录更新到最新的注册表版本 + +### 修复 + +- 减少了 Agent 流式输出时切换工作区导致的移动端聊天冻结和白屏 ([#1989](https://github.com/getpaseo/paseo/pull/1989)) +- OpenCode 会话可以可靠地启动,不再偶尔丢失第一个回合 ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger)) +- 在工作区之间切换不再闪白屏 +- Paseo 添加自己的 MCP 工具时,Pi 保留你已有的 MCP 工具和设置 ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger)) + +## 0.1.105 - 2026-07-10 + +### 新增 + +- 可以以可折叠的文件夹树或平铺列表浏览已更改的文件 ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter)) +- 通过新的外观设置,可以始终展开 Agent 的推理 ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger)) + +### 改进 + +- 项目选择器支持模糊搜索文件夹和桌面端原生浏览 ([#1968](https://github.com/getpaseo/paseo/pull/1968)) +- 大型工作区侧边栏保持响应 ([#1966](https://github.com/getpaseo/paseo/pull/1966)) +- 生成的工作区名称和 Git 文本可以使用 MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch)) +- Cursor 现在提供思考和 fast 模式 ([#1952](https://github.com/getpaseo/paseo/pull/1952)) + +### 修复 + +- 子 Agent 运行期间 Codex 保持活动并正确地流式输出 ([#1967](https://github.com/getpaseo/paseo/pull/1967)) +- Android 音频中断不再导致语音模式崩溃或语音输入卡住 ([#1941](https://github.com/getpaseo/paseo/pull/1941)) +- 移动端侧边栏保持同步,并保留滑动打开手势 ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976)) +- Pi 的纯文本模型可以接受图片提示词而不会破坏会话 ([#1960](https://github.com/getpaseo/paseo/pull/1960)) +- 应用渲染失败时显示可重试的恢复界面,而不是白屏 ([#1924](https://github.com/getpaseo/paseo/pull/1924)) +- 使用较旧版本的 Oh My Pi 时 Pi 上下文用量仍然可见 ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava)) +- 快速打开和关闭 Provider 用量弹出框不再报错 ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava)) +- 移动端工作区菜单隐藏仅适用于桌面端的快捷键标记 ([#1964](https://github.com/getpaseo/paseo/pull/1964)) + +## 0.1.104 - 2026-07-08 + +### 新增 + +- Agent 可以通过页面快照、受信任的输入、对话框和标签页控制来驱动应用内浏览器 ([#1881](https://github.com/getpaseo/paseo/pull/1881)) +- 可以从浏览器标签页检查、标注页面元素并将其发送给 Agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning)) +- 新增 Schedules 界面,用于创建和管理周期性运行的 Agent ([#1246](https://github.com/getpaseo/paseo/pull/1246)) +- 可以在任何地方用 Cmd+O 打开项目 ([#1849](https://github.com/getpaseo/paseo/pull/1849)) +- Agent 在理解任务之后可以重命名工作区 ([#1876](https://github.com/getpaseo/paseo/pull/1876)) +- 支持的 Claude 模型可以使用 Claude Ultra Code ([#1872](https://github.com/getpaseo/paseo/pull/1872)) +- 新增字节跳动 TRAE CLI 作为 Agent Provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er)) + +### 改进 + +- 可以在桌面端设置中的同一个位置管理内置 daemon ([#1938](https://github.com/getpaseo/paseo/pull/1938)) +- 定时运行和循环运行在侧边栏中各自拥有独立的工作区 ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934)) +- 应用中大量 Provider 和模型的刷新加载更快 ([#1895](https://github.com/getpaseo/paseo/pull/1895)) +- Agent 创建的工作区现在会得到可读的生成名称 ([#1887](https://github.com/getpaseo/paseo/pull/1887)) +- Agent 打开的浏览器标签页会保持在后台,直到你切换过去 ([#1875](https://github.com/getpaseo/paseo/pull/1875)) +- Agent 提问时的卡片更清晰 ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter)) +- 诊断报告包含桌面应用日志 ([#1914](https://github.com/getpaseo/paseo/pull/1914)) +- Paseo 的内置工具占用更少的上下文 ([#1939](https://github.com/getpaseo/paseo/pull/1939)) + +### 修复 + +- 重命名的主机在重新连接后保留其名称 ([#1940](https://github.com/getpaseo/paseo/pull/1940)) +- 即使 shell 启动较慢,桌面端也能找到你已安装的 CLI ([#1916](https://github.com/getpaseo/paseo/pull/1916)) +- 从桌面端设置重启 daemon 可以可靠地工作 ([#1915](https://github.com/getpaseo/paseo/pull/1915)) +- 从内置的 CLI 重启 daemon 后,它仍由桌面应用管理 ([#1919](https://github.com/getpaseo/paseo/pull/1919)) +- 从内置的 CLI 启动 daemon 时 Web UI 可以加载 ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim)) +- Worktree 设置脚本保留你的 PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908)) +- Docker 镜像在 Provider 清理和诊断期间保持运行 ([#1877](https://github.com/getpaseo/paseo/pull/1877)) +- 归档工作区后 New Workspace 草稿得以保留 ([#1838](https://github.com/getpaseo/paseo/pull/1838)) +- 切换界面后 Composer 自动补全保持打开 ([#1851](https://github.com/getpaseo/paseo/pull/1851)) +- 配额窗口没有计划的重置时间时也会显示 Claude 用量 ([#1855](https://github.com/getpaseo/paseo/pull/1855)) +- 侧边栏中的非 git 项目会显示 New workspace 操作 ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter)) + +## 0.1.103 - 2026-07-01 + +### 新增 + +- Claude 模型选择器中可以使用 Claude Sonnet 5 ([#1850](https://github.com/getpaseo/paseo/pull/1850)) + +## 0.1.102 - 2026-06-30 + +### 新增 + +- 可以把聊天 fork 到新标签页或新 worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788)) +- 可以查看所有已连接主机上的工作区 ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825)) +- daemon 现在可以提供 Web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739)) +- 可以使用官方 Docker 镜像运行 Paseo ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant)) +- 可以从应用远程更新 daemon ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng)) +- 可以为语音转文字和文字转语音分别配置 OpenAI 端点 ([#1823](https://github.com/getpaseo/paseo/pull/1823)) +- 可以把文件拖放到任何 Composer 中 ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801)) +- 配额视图中显示 MiniMax 用量 ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood)) +- 新增 C# 代码块高亮 ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693)) + +### 改进 + +- 可以在任何地方打开 New Workspace ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806)) +- 项目搜索显示加载进度 ([#1762](https://github.com/getpaseo/paseo/pull/1762)) +- 桌面端更新检查显示更清晰的状态 ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815)) +- 较慢的远程主机不再那么容易超时 ([#1789](https://github.com/getpaseo/paseo/pull/1789)) +- Pi 会等待更长时间以获取扩展结果 ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava)) +- 重新访问已打开的文件标签页时会刷新 ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter)) +- Web 终端滚动更流畅 ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike)) + +### 修复 + +- 新添加的项目无需重启即可编辑 ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning)) +- 大型仓库打开更可靠 ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830)) +- 移动端启动时恢复已保存的工作区 ([#1777](https://github.com/getpaseo/paseo/pull/1777)) +- Agent 提示词不再重命名工作区 ([#1779](https://github.com/getpaseo/paseo/pull/1779)) +- 延迟到达的历史不再让聊天移动位置 ([#1776](https://github.com/getpaseo/paseo/pull/1776)) +- 流式输出的聊天图片保持顺序 ([#1805](https://github.com/getpaseo/paseo/pull/1805)) +- 聊天操作保持在工具输出下方 ([#1827](https://github.com/getpaseo/paseo/pull/1827)) +- Claude 子 Agent 的旁白不再出现在聊天中 ([#1807](https://github.com/getpaseo/paseo/pull/1807)) +- Kiro 的斜杠命令和技能出现在 Paseo 中 ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er)) +- 存在过时的项目记录时 Agent 列表仍可正常显示 ([#1812](https://github.com/getpaseo/paseo/pull/1812)) +- Windows 图片预览可以处理带盘符的路径 ([#1811](https://github.com/getpaseo/paseo/pull/1811)) +- OpenCode 在 Windows 上可以正常关闭 ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto)) +- 桌面端上传的文件保留其扩展名 ([#1741](https://github.com/getpaseo/paseo/pull/1741)) +- Claude Code 清理时会终止子进程 ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike)) +- OpenCode 不再索引你的主目录 ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang)) +- 打包的 macOS CLI daemon 不再显示额外的 Dock 图标 ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim)) +- `paseo daemon status` 无需加载 Agent 即可工作 ([#1810](https://github.com/getpaseo/paseo/pull/1810)) +- PR worktree 正确显示已推送状态 ([#1804](https://github.com/getpaseo/paseo/pull/1804)) + +## 0.1.101 - 2026-06-26 + +### 新增 + +- 当支持人员需要主机、daemon、Provider 和日志详情时,可以从 Settings 复制故障排查报告 ([#1728](https://github.com/getpaseo/paseo/pull/1728)) +- Claude 图片工具结果现在在聊天中渲染为图片 ([#1717](https://github.com/getpaseo/paseo/pull/1717)) +- 新增日语 ([#1694](https://github.com/getpaseo/paseo/pull/1694) by [@sysCat64](https://github.com/sysCat64)) +- 新增巴西葡萄牙语 ([#1653](https://github.com/getpaseo/paseo/pull/1653) by [@Alcimerio](https://github.com/Alcimerio)) + +### 改进 + +- 即使模型发现较慢,Provider 诊断仍然有用 ([#1724](https://github.com/getpaseo/paseo/pull/1724)) +- 较慢的 Provider 请求不再让应用看起来像已断开 ([#1723](https://github.com/getpaseo/paseo/pull/1723)) +- 关联到名称不同的跟踪分支的 worktree 可以正确找到其 PR ([#1718](https://github.com/getpaseo/paseo/pull/1718)) +- 由斜杠命令提示词启动的工作区会得到更清晰的名称 ([#1709](https://github.com/getpaseo/paseo/pull/1709)) +- ACP Provider 目录更新到最新的注册表版本 + +### 修复 + +- Pi 在加载新 Agent 选项时不再创建空会话 ([#1727](https://github.com/getpaseo/paseo/pull/1727)) +- Windows 上的 daemon 状态能更可靠地找到 daemon 进程 ([#1725](https://github.com/getpaseo/paseo/pull/1725)) +- OpenAI 语音凭据不再影响其他基于 OpenAI 的工具 +- Provider 模型列表在刷新期间不再消失 + +## 0.1.100 - 2026-06-24 + +### 新增 + +- 可以用 Shift+Tab 循环切换 Agent 模式 +- 启动时或会话中可以选择自定义的 Copilot Agent ([#1700](https://github.com/getpaseo/paseo/pull/1700)) + +### 改进 + +- ACP Provider 目录更新到最新的注册表版本 + +### 修复 + +- Claude 不再在每条消息之后发送额外的 API 请求 ([#1701](https://github.com/getpaseo/paseo/pull/1701)) +- 会话结束后 OpenCode 不再遗留在后台运行的服务器 ([#1697](https://github.com/getpaseo/paseo/pull/1697)) +- 斜杠命令和技能现在可以在 OMP Agent 中加载 ([#1698](https://github.com/getpaseo/paseo/pull/1698)) + +## 0.1.99 - 2026-06-23 + +### 改进 + +- PR 面板现在有刷新按钮和更清晰的加载状态 ([#1664](https://github.com/getpaseo/paseo/pull/1664)) +- Provider 诊断和模型列表现在保持同步 ([#1660](https://github.com/getpaseo/paseo/pull/1660)) + +### 修复 + +- Grok 等 ACP Provider 不再显示重复的用户消息 +- 已保存的 Composer 模式在 Provider 数据加载期间不再被重置 ([#1658](https://github.com/getpaseo/paseo/pull/1658)) +- 右侧边栏在移动端不再卡住 ([#1661](https://github.com/getpaseo/paseo/pull/1661)) + +## 0.1.98 - 2026-06-21 + +### 新增 + +- 可以在应用内查看 Claude、Codex、Copilot、Cursor、Z.AI、Grok 和 Kimi 的套餐用量 ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati)) +- 为 Claude 新增 Ultracode ([#1625](https://github.com/getpaseo/paseo/pull/1625)) +- 可以分离子 Agent 让它独立运行 ([#1612](https://github.com/getpaseo/paseo/pull/1612)) +- 可以添加项目而不创建工作区 +- 新增在侧边栏中显示分支名而不是标题的设置 + +### 改进 + +- 回合中途的思考和模式更改现在会说明它们在下一个回合生效 +- PR 合并选项会注明其方式:squash、merge 或 rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger)) +- 正在运行的 Agent 的模式更改会被新 Agent 记住 +- 可以一键复制 Provider 的启动诊断 ([#1611](https://github.com/getpaseo/paseo/pull/1611)) + +### 修复 + +- OpenCode 在 macOS 桌面端不再扫描整个磁盘 ([#1626](https://github.com/getpaseo/paseo/pull/1626)) +- OpenAI 语音没有 API 密钥时 daemon 不再崩溃 ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger)) +- 重新打开已归档的 Codex Agent 不再挂起 +- Claude 的上下文计量不再跳到子 Agent 的用量 +- 在新会话中,Claude 的上下文计量从第一条消息开始填充 +- OpenCode 的模式选择器现在会遵循你禁用的模式 ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger)) +- 文件链接和 @ 提及可以找到点开头的文件夹和深层路径中的文件 ([#1609](https://github.com/getpaseo/paseo/pull/1609)) +- 归档项目的最后一个工作区不再让项目消失 ([#1631](https://github.com/getpaseo/paseo/pull/1631)) +- 折叠的侧边栏项目保持折叠 + +## 0.1.97 - 2026-06-18 + +### 新增 + +- **简化的工作区模型** — 无需 worktree 即可在同一份代码上运行多个工作区,每个工作区都有自己的 Agent、终端和状态 ([#1539](https://github.com/getpaseo/paseo/pull/1539)) +- **从 History 重新打开已归档的工作区** — 即使 worktree 已被移除,也能恢复过去的工作区 +- **终端会显示其 Agent 是在工作、空闲还是等待输入** ([#1507](https://github.com/getpaseo/paseo/pull/1507)) +- **在移动端为 Agent 附加文件** ([#1501](https://github.com/getpaseo/paseo/pull/1501)) +- **在文件浏览器中隐藏点文件** ([#1516](https://github.com/getpaseo/paseo/pull/1516) by [@yuruiz](https://github.com/yuruiz)) +- **把终端、浏览器和新标签页按钮固定到标签栏和侧边栏** +- **用键盘快捷键创建新工作区** + +### 改进 + +- 工作区标题来自你的第一条提示词,并且更短 ([#1563](https://github.com/getpaseo/paseo/pull/1563)) +- 可以从工作区的悬停卡片复制其分支或路径 +- 大量输出时终端保持流畅 ([#1500](https://github.com/getpaseo/paseo/pull/1500)) +- 最后一个工作区被归档时会移除 worktree ([#1562](https://github.com/getpaseo/paseo/pull/1562)) +- 完成通知包含子 Agent 的结果 ([#1558](https://github.com/getpaseo/paseo/pull/1558)) +- Cursor 只列出你可以选择的模型 ([#1556](https://github.com/getpaseo/paseo/pull/1556)) +- ACP Provider 目录更新到最新的注册表版本 + +### 修复 + +- daemon 的短暂变慢不再导致连接断开 +- Linux AppImage 更新不再在退出时挂起或删除应用 ([#1485](https://github.com/getpaseo/paseo/pull/1485) by [@xpufx](https://github.com/xpufx)) +- 在 Android 上打开 Providers 设置不再崩溃 ([#1537](https://github.com/getpaseo/paseo/pull/1537)) +- 编码 Agent 的终端快捷键在 Windows 上可以使用 ([#1509](https://github.com/getpaseo/paseo/pull/1509)) +- 可以再次导入 ACP 和 Kimi 会话 ([#1510](https://github.com/getpaseo/paseo/pull/1510) by [@wbxl2000](https://github.com/wbxl2000)) +- ACP Agent 关闭时不再遗留孤立进程 ([#1460](https://github.com/getpaseo/paseo/pull/1460) by [@yeshan333](https://github.com/yeshan333)) +- 导入会话的预览显示干净的提示词 ([#1502](https://github.com/getpaseo/paseo/pull/1502)) +- 本地配对邀请使用正确的应用 URL ([#1187](https://github.com/getpaseo/paseo/pull/1187) by [@aibaiiqpl](https://github.com/aibaiiqpl)) +- 应用不再因为反复重新探测 Provider 而冻结 +- 从侧边栏移除项目现在会移除项目本身,而不是把它遗留下来 +- 工作区快捷键数字不再显示在错误的按键上 ([#1580](https://github.com/getpaseo/paseo/pull/1580) by [@cleiter](https://github.com/cleiter)) +- 消息包含不成对的反引号时聊天不再挂起 ([#1585](https://github.com/getpaseo/paseo/pull/1585) by [@thaning0](https://github.com/thaning0)) + +## 0.1.96 - 2026-06-13 + +_此版本只修复了一个 Android 问题,桌面端用户无需更新。_ + +### 修复 + +- 在 Android 上打开聊天后,侧边栏不再重新出现并卡住 + +## 0.1.95 - 2026-06-13 + +### 新增 + +- **在桌面端为 Agent 附加任意文件** ([#1474](https://github.com/getpaseo/paseo/pull/1474)) + +### 改进 + +- 分支领先时,git 推送按钮显示在合并操作之前 ([#1488](https://github.com/getpaseo/paseo/pull/1488)) +- SVG 附件会上传到磁盘 +- 切换工作区更流畅 + +### 修复 + +- 修复了可能显示过时 GitHub 数据的情况 ([#1491](https://github.com/getpaseo/paseo/pull/1491)) +- PR 评论和审查讨论中上传的图片现在可以在 PR 面板中加载 ([#1486](https://github.com/getpaseo/paseo/pull/1486)) +- 打开文件夹缺失的项目时显示清晰的错误 ([#1490](https://github.com/getpaseo/paseo/pull/1490)) +- 新工作区标题会避开键盘 ([#1489](https://github.com/getpaseo/paseo/pull/1489)) +- Android 上侧边栏不再自行打开 + +## 0.1.94 - 2026-06-12 + +### 新增 + +- **从 PR 面板把拉取请求的评论、审查、讨论和失败检查的日志附加到聊天** ([#1400](https://github.com/getpaseo/paseo/pull/1400)) +- **支持阿拉伯语、中文、英语、法语、俄语和西班牙语界面** ([#1282](https://github.com/getpaseo/paseo/pull/1282), [#1478](https://github.com/getpaseo/paseo/pull/1478) by [@chyendongnhanh338](https://github.com/chyendongnhanh338), [@dwyanewang](https://github.com/dwyanewang)) +- **从 Host 设置创建可复用的终端配置** +- **在 Antigravity 中打开工作区** ([#1424](https://github.com/getpaseo/paseo/pull/1424) by [@krumpyzoid](https://github.com/krumpyzoid)) + +### 改进 + +- 输入时 Claude 技能会出现在提示词自动补全中 ([#1464](https://github.com/getpaseo/paseo/pull/1464)) +- 可以直接从文件预览标签页菜单复制文件路径 ([#1473](https://github.com/getpaseo/paseo/pull/1473)) +- Agent 合并分支后 PR 状态保持最新 ([#1455](https://github.com/getpaseo/paseo/pull/1455)) +- 只保留当前活动工作区的界面,使工作区标签页保持快速 ([#1472](https://github.com/getpaseo/paseo/pull/1472)) + +### 修复 + +- Composer 的发送快捷键不再与其他键盘快捷键冲突 +- 多问题提示每次推进一个答案 ([#1462](https://github.com/getpaseo/paseo/pull/1462)) +- 导入的 Pi 会话保留其原来的模型和思考设置 ([#1441](https://github.com/getpaseo/paseo/pull/1441) by [@thomasaull](https://github.com/thomasaull)) +- 重新连接桌面主机时保留已保存的 shell 和工作区路由 +- Worktree 终端不再出现在父工作区中 +- 移动端重新连接时正确显示欢迎界面 + +## 0.1.93 - 2026-06-10 + +### 新增 + +- **Claude 模型选择器中可以使用 Claude Fable 5** ([#1443](https://github.com/getpaseo/paseo/pull/1443) by [@0-Captain](https://github.com/0-Captain)) + +## 0.1.92 - 2026-06-10 + +### 新增 + +- **提示词中的技能自动补全** + +### 改进 + +- Provider 目录内嵌在 Host 设置中 ([#1423](https://github.com/getpaseo/paseo/pull/1423)) +- 手动检查更新会跳过分阶段发布的延迟 +- Provider 目录中 CodeWhale 取代了 DeepSeek TUI +- 更新了 ACP Provider 目录中 Cline、Codebuddy Code、DimCode、Factory Droid、Gemini、Nova 和 Qoder 的条目 +- OMP 有了自己的图标和网站页面 +- 模型选择器的描述更清晰 +- ACP Provider 错误会显示 Provider 实际的失败消息 + +### 修复 + +- 新的 Paseo worktree 分支可以推送其第一批提交 +- 导入的会话不再打开为空白或打开到错误的工作区 +- Windows 资源管理器打开所选的工作区而不是 Documents ([#1412](https://github.com/getpaseo/paseo/pull/1412) by [@bjspi](https://github.com/bjspi)) +- 以命令 shim 方式安装的 Windows 编辑器快捷方式可以正确启动 ([#1387](https://github.com/getpaseo/paseo/pull/1387) by [@Peter7896](https://github.com/Peter7896)) +- 无法使用 MCP 服务器的 ACP Provider 可以正确启动 +- 已移除的主机不再让主机页面卡在连接中 +- 文件预览中的链接在外部浏览器中打开 +- 输出流式显示时聊天保持固定在最新消息 +- 输入时移动端 Composer 的发送按钮不再移动 + +## 0.1.91 - 2026-06-08 + +### 新增 + +- **打开多个桌面窗口** ([#1355](https://github.com/getpaseo/paseo/pull/1355) by [@arieel-ost](https://github.com/arieel-ost)) +- **在工作区标签页中打开浏览器弹窗和链接** ([#1375](https://github.com/getpaseo/paseo/pull/1375)) +- **在移动端使用 Command Center** +- **新增 OMP Provider** ([#1388](https://github.com/getpaseo/paseo/pull/1388)) + +### 改进 + +- 新工作区会记住你上次选择的 Provider、模式和思考设置 +- Git 控件现在对已就绪的分支默认提供拉取请求,并隐藏不可用的拉取或推送操作 +- 桌面端管理的主机在 daemon 状态过时后恢复更可靠 +- daemon 状态现在会解释认证失败的原因 +- 项目搜索会跳过 Python 虚拟环境 ([#1356](https://github.com/getpaseo/paseo/pull/1356)) +- 配置文件可以包含 `$schema` 以获得编辑器帮助 +- Claude MCP 服务器保留始终加载工具的设置 ([#1333](https://github.com/getpaseo/paseo/pull/1333) by [@nodomain](https://github.com/nodomain)) +- Claude 配置保留其配置的模型 ([#1311](https://github.com/getpaseo/paseo/pull/1311) by [@ilteoood](https://github.com/ilteoood)) +- 在较慢的机器上,Provider 加载可以等待更长时间 ([#1346](https://github.com/getpaseo/paseo/pull/1346) by [@nodomain](https://github.com/nodomain)) +- Kimi 目录条目现在指向 Kimi Code CLI ([#1403](https://github.com/getpaseo/paseo/pull/1403) by [@wbxl2000](https://github.com/wbxl2000)) +- 更新了 ACP Provider 目录中 Auggie、Claude Agent、Cline、Codebuddy Code、DimCode、Factory Droid、fast-agent、Gemini、GitHub Copilot 和 Nova 的条目 +- 本地语音输入的崩溃报告显示更有用的细节 ([#1379](https://github.com/getpaseo/paseo/pull/1379)) +- daemon 日志会显示被管理的 worker 退出的原因 + +### 修复 + +- Pi 的压缩斜杠命令可以正确运行 ([#1338](https://github.com/getpaseo/paseo/pull/1338) by [@chyendongnhanh338](https://github.com/chyendongnhanh338)) +- 已合并 PR 的分支被删除后自动归档仍然有效 ([#1378](https://github.com/getpaseo/paseo/pull/1378)) +- Worktree 可以正确检出已有的分支 ref ([#1358](https://github.com/getpaseo/paseo/pull/1358) by [@dixonl90](https://github.com/dixonl90)) +- 启用 daemon 密码保护时文件下载可以正常工作 ([#1351](https://github.com/getpaseo/paseo/pull/1351) by [@nodomain](https://github.com/nodomain)) +- iOS 上的 Markdown 链接可以再次点击 ([#1334](https://github.com/getpaseo/paseo/pull/1334) by [@kaspesi](https://github.com/kaspesi)) +- iOS 上的 Markdown 图片正确渲染 +- Windows 工作区可以正确加载其 Provider ([#1329](https://github.com/getpaseo/paseo/pull/1329)) +- 移除 localhost 主机会停止其本地 daemon ([#1297](https://github.com/getpaseo/paseo/pull/1297) by [@mcowger](https://github.com/mcowger)) +- Provider 设置面板正确层叠 +- 新工作区界面不再在移动端侧边栏后面打开 +- 全局 Agent 列表恢复正常 ([#1420](https://github.com/getpaseo/paseo/pull/1420)) +- OpenCode 压缩摘要不再出现在聊天中 +- 共享同一工作区的 OpenCode Agent 各自保留自己的 Paseo 工具 + +## 0.1.90 - 2026-06-04 + +### 新增 + +- **按状态对侧边栏分组,一眼就能看到等待你处理、可以审查、工作中和已完成的工作区** ([#1317](https://github.com/getpaseo/paseo/pull/1317)) +- **无需先选择项目,即可从全局侧边栏按钮开始一个新工作区** ([#1324](https://github.com/getpaseo/paseo/pull/1324)) +- **直接在编辑器、文件管理器或 GitHub 中打开当前文件,而不只是打开工作区根目录** ([#1285](https://github.com/getpaseo/paseo/pull/1285) by [@aaronzhongg](https://github.com/aaronzhongg)) +- **可以在主机设置中开启:PR 合并后自动归档干净的 PR 工作区** ([#1313](https://github.com/getpaseo/paseo/pull/1313)) +- **安装较新的桌面构建后,桌面端管理的 Paseo 技能保持最新** ([#1309](https://github.com/getpaseo/paseo/pull/1309)) +- **Dart 文件和 Dart 代码块现在支持语法高亮** ([#1326](https://github.com/getpaseo/paseo/pull/1326)) + +### 改进 + +- 侧边栏中可以审查或已失败的工作区可以标记为已读 ([#1317](https://github.com/getpaseo/paseo/pull/1317)) +- 跨 Provider 委派时,子 Agent 保留无人值守的权限 ([#1315](https://github.com/getpaseo/paseo/pull/1315)) +- 定时运行的 Agent 打开时显示真实的提示词和标题,而不是看起来空空的 ([#1316](https://github.com/getpaseo/paseo/pull/1316)) +- Git 控件优先提供让已就绪的分支得以发布的操作 ([#1316](https://github.com/getpaseo/paseo/pull/1316)) +- 多个 Agent 问题逐个显示 +- 带有自由填写答案的 OpenCode 问题会在 Paseo 中显示输入的回复 +- 委派的 Agent 活动在父工作区中可见 +- 会话按最近活动排序 +- 更新了 ACP Provider 目录中 Claude Agent、Cline、Codebuddy Code、Factory Droid 和 Qoder 的条目 + +### 修复 + +- 时间线追赶不再遗留未加载的较早消息 +- 文件预览中的 Markdown 代码正确渲染 +- 较长的语音输入重试不再阻塞新的音频 +- 可以从主机设置页面使用 Settings 的主机选择器导航 +- Diff 行号栏与更改的代码保持对齐 +- 负载较高时移动端侧边栏手势保持响应 +- 紧凑的底部面板保持显示其页脚和底部间距 + +## 0.1.89 - 2026-06-02 + +### 新增 + +- **通过公开的服务代理链接打开工作区服务** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger)) +- **选择新 worktree 的创建位置** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger)) +- **桌面窗口以相同的大小和位置重新打开** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn)) +- **委派的 Agent 可以独立运行并定期发送 heartbeat 更新** + +### 改进 + +- Composer 控件更适合较窄的窗格 +- fork 拉取请求的徽标在 worktree 中保持可见 +- ACP 目录中的 Cline 更新到 v3 + +### 修复 + +- 即使清理过程出错,归档 worktree 也能完成 ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger)) +- iOS 聊天消息正确渲染粗体、斜体、删除线和换行 ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting)) +- 在右边缘调整拆分窗格大小时不再被裁切 ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn)) +- Pi 扩展命令的输出不再挂起 +- 委派的 Agent 不再计入工作区提醒数量 + +## 0.1.88 - 2026-06-01 + +### 新增 + +- **在新的 Appearance 设置中选择应用主题** +- **设置自定义界面字体** +- **设置自定义代码字体** +- **调整界面文字大小** +- **调整代码文字大小** +- **选择语法高亮主题** +- **让 cron 定时任务对齐到所选的时区** ([#1232](https://github.com/getpaseo/paseo/pull/1232) by [@damselem](https://github.com/damselem)) + +### 改进 + +- Settings 现在采用带有主机选择器的扁平侧边栏 +- 工作区标签页切换更快 +- 紧凑的 Composer 现在以百分比显示上下文用量 +- 在工作区子目录中打开的 Agent 终端现在与其他工作区终端显示在一起 +- 桌面应用打开时 macOS 显示器可以正常进入空闲 ([#1242](https://github.com/getpaseo/paseo/pull/1242) by [@fireblue](https://github.com/fireblue)) +- 生成的大型 diff 现在显示清晰的“过大”占位符,而不是尝试渲染整个文件 + +### 修复 + +- 在长时间运行的工具更新前后,聊天历史可以正确追赶 +- 拆分或调整窗格大小后终端窗格保持正确的尺寸 +- 窗格尺寸变化后,恢复的终端快照可以正确重排 +- 启动服务后工作区脚本菜单保持正确的尺寸 +- iOS 聊天消息不再隐藏行内链接、URL 或带链接的文件路径 ([#1257](https://github.com/getpaseo/paseo/pull/1257) by [@outofrange-consulting](https://github.com/outofrange-consulting)) + +## 0.1.87 - 2026-05-30 + +### 新增 + +- OpenCode 子 Agent 的权限提示现在会在 Paseo 中显示,以便你批准或拒绝 + +### 修复 + +- 修复了动画视图绘制时 Android 间歇性崩溃的问题 +- 修复了移动端底部面板关闭后无法重新打开的问题 + +## 0.1.86 - 2026-05-29 + +### 新增 + +- **以编码 Agent 的形式启动 Grok (xAI)** +- **Claude Opus 的 Fast 模式** +- **使用新的 Parakeet v3 语音模型进行多语言本地语音输入** + +### 改进 + +- Edit、Write 和 Read 工具调用现在支持语法高亮 +- Provider 加载失败时模型选择器会显示错误 +- About 页面显示已连接主机的 daemon 版本 +- 可以通过新的刷新按钮按需刷新 git diff +- 预览可以打开当前工作区之外的可读文件 +- 没有图标的项目现在显示彩色图标,而不是灰色占位符 +- 自动生成的 Agent 标题和 worktree 分支名现在使用你配置的 Provider 回退 ([#1219](https://github.com/getpaseo/paseo/pull/1219) by [@mcowger](https://github.com/mcowger)) +- 本地语音输入不再把语音模型放在 daemon 中,从而降低其内存占用 + +### 修复 + +- 在移动端,整个 Composer 现在保持在键盘上方,子 Agent 栏和草稿胶囊不再被键盘挡住 +- 重新连接后移动端 Agent 时间线会完整追赶,不会丢失消息 +- 斜杠命令菜单不再显示两次 /clear + +## 0.1.85 - 2026-05-29 + +### 新增 + +- **Claude 模型选择器中新增 Opus 4.8**,并提供 1M 上下文的变体 + +### 改进 + +- 归档 worktree 现在会把其 Agent 保留在已归档列表中,而不是移除它们 +- 归档 Agent 时会清理以它为目标的定时任务 + +## 0.1.84 - 2026-05-28 + +### 新增 + +- **OpenCode Agent 自动接受工具调用** + +### 改进 + +- 可以复制 OpenCode 的恢复命令,在 Paseo 之外继续该会话 +- 模型选择器列出所有已启用的 Provider,某个 Provider 加载失败时提供 Retry 按钮 +- Provider 设置更易于搜索和管理 +- 通过 MCP 连接到 Paseo 的其他 Agent 看到的 Provider、模型和模式与应用中一致 ([#1198](https://github.com/getpaseo/paseo/pull/1198)) +- OpenCode Edit 工具调用渲染为内联 diff +- 输入斜杠命令时最佳匹配排在最前面 +- 在包含许多 git 文件夹的工作区中 daemon 启动更快 +- Markdown 列表的间距更紧凑 +- 流式输出 Agent 回复时卡顿更少 +- 用户消息的页脚控件与聊天的其余部分对齐 +- Agent 模式控件采用更简洁的单色样式 +- 紧凑布局把上下文圆环移到页脚的右边缘 + +### 修复 + +- 移动端可以在聊天中选择文本 ([#1153](https://github.com/getpaseo/paseo/pull/1153) by [@muzhi1991](https://github.com/muzhi1991)) +- 提交 Pi 问题后不再看起来像是打开了第二个提示词 ([#1188](https://github.com/getpaseo/paseo/pull/1188) by [@yuruiz](https://github.com/yuruiz)) +- 修复了工作区 git 缓存无上限导致的 daemon 内存泄漏 ([#1200](https://github.com/getpaseo/paseo/pull/1200)) +- Provider 诊断包含命令覆盖的二进制路径 ([#1191](https://github.com/getpaseo/paseo/pull/1191)) +- daemon 绑定到通配地址时 OpenCode MCP 服务器可以正确连接 +- 返回不符合规范输出的 MCP 服务器的工具调用不再校验失败 + +## 0.1.83 - 2026-05-26 + +### 修复 + +- 通过 MCP 创建 Agent 现在会等待它真正启动,因此失败会以明确的创建错误显示 +- 通过 MCP 为 Agent 设置定时任务不再拒绝空白的频率占位符 +- 在没有思考选项的模型上,草稿消息重新显示 Agent 模式标签 + +## 0.1.82 - 2026-05-26 + +### 新增 + +- **从任意一条用户消息回退聊天或文件** ([#1154](https://github.com/getpaseo/paseo/pull/1154)) +- **查看 Agent 会话的累计费用** ([#1163](https://github.com/getpaseo/paseo/pull/1163)) +- **把文件拖放到终端以插入其路径** ([#1173](https://github.com/getpaseo/paseo/pull/1173)) +- **点击终端中的文件路径,在工作区预览中打开它** ([#1174](https://github.com/getpaseo/paseo/pull/1174)) +- **为整个会话批准 OpenCode 权限** ([#1168](https://github.com/getpaseo/paseo/pull/1168)) +- **工作区脚本现在显示在移动端标题栏中** ([#1093](https://github.com/getpaseo/paseo/pull/1093) by [@ayhanmalkoc](https://github.com/ayhanmalkoc)) +- ACP Provider 目录新增 Devin CLI (by [@Alcimerio](https://github.com/Alcimerio)) +- OpenCode Agent 显示其模式颜色 + +### 改进 + +- 打开侧边栏时移动端终端键盘会隐藏 +- 读取、写入和 OpenCode 工具的活动渲染更加一致 ([#1171](https://github.com/getpaseo/paseo/pull/1171)) +- 紧凑的工作区标题栏操作更整洁 +- Settings 中的延迟读数更易于浏览 ([#1170](https://github.com/getpaseo/paseo/pull/1170)) +- GitHub 报告 PR 已就绪后即可合并拉取请求 ([#1172](https://github.com/getpaseo/paseo/pull/1172)) + +### 修复 + +- 移动端斜杠命令自动补全不再闪烁或层级错乱 +- 中断 OpenCode Agent 会让它回到空闲状态,而不是显示错误 ([#1169](https://github.com/getpaseo/paseo/pull/1169)) +- 遵循按工作区选择的 Provider 模型 ([#1167](https://github.com/getpaseo/paseo/pull/1167)) +- 草稿 Composer 保留你选择的权限模式 ([#1175](https://github.com/getpaseo/paseo/pull/1175)) +- 终端颜色查询不再返回格式错误的回复 +- 消息包含单独的 '%' 时聊天中的文件链接不再崩溃 (by [@Elliotwu-7](https://github.com/Elliotwu-7)) + +## 0.1.81 - 2026-05-24 + +### 新增 + +- **现在可以从支持的浏览器把 Paseo 安装为 Web 应用** ([#1144](https://github.com/getpaseo/paseo/pull/1144)) +- **Pi 扩展对话框现在以 Paseo 权限提示的形式出现** ([#1134](https://github.com/getpaseo/paseo/pull/1134) by [@yuruiz](https://github.com/yuruiz)) +- 侧边栏新增社区链接和主页按钮 + +### 改进 + +- **移动端终端加载更快,恢复已有输出更流畅** ([#1147](https://github.com/getpaseo/paseo/pull/1147)) +- 复制助手消息时保留格式 +- Agent 元数据回退失败时会记录每个 Provider 的尝试,便于调试 + +### 修复 + +- Android:从 Composer 打开的斜杠命令建议保持可交互 +- macOS:Alt+字母快捷键恢复正常 +- 调整大小时终端窗格不再闪烁 +- OpenCode MCP 服务器只注入一次,而不是连接两次 +- Import session 不再显示空会话 +- Worktree 归档状态不再误报有未推送的提交 ([#1158](https://github.com/getpaseo/paseo/pull/1158)) +- `/exit`、`/quit` 和 `/q` 斜杠命令别名现在显示为一行 +- 浅色模式下组合快捷键标记清晰可读 +- 分段控件在每个分段下方都显示轨道 +- 深色模式下底部面板标题栏中的搜索文本清晰可读 + +## 0.1.80 - 2026-05-21 + +### 修复 + +- 在移动端打开下拉菜单不再崩溃 + +## 0.1.79 - 2026-05-21 + +### 新增 + +- **Pi 全面改版,获得一流支持** + - 通过你已安装的 Pi CLI 运行,因此你的 Pi 扩展和配置都会沿用 + - 安装了 Pi MCP 扩展后,Pi Agent 可以调用 Paseo 工具 + - 可以导入你在终端中启动的 Pi 会话 + - 可以从任意 Agent 复制 Pi 的恢复命令,在终端中继续该会话 + - Windows:通过符号链接和目录联接访问的工作区路径中,Pi 会话能正确匹配 +- **新的主屏幕,提供添加项目、导入会话、设置 Provider 和配对设备的快捷图块** +- **直接在一个全新的 worktree 中创建 Agent,运行结束后自动归档** +- **设置适用于你启动的每个 Agent 的自定义系统提示词** +- **重命名工作区、终端和 Agent 标签页** ([#531](https://github.com/getpaseo/paseo/pull/531)) +- **ACP Provider 目录新增 DeepSeek TUI** ([#1096](https://github.com/getpaseo/paseo/pull/1096)) +- **ACP Provider 目录新增 Kiro CLI** (by [@huhusmang](https://github.com/huhusmang)) +- 目录中的 Provider 在模型选择器中显示其图标 ([#1098](https://github.com/getpaseo/paseo/pull/1098)) +- 创建 Agent 时传入的自定义环境变量现在会传递到 Agent 进程 ([#1112](https://github.com/getpaseo/paseo/pull/1112)) +- NixOS 模块支持自托管中继的公开 TLS 选项 ([#1106](https://github.com/getpaseo/paseo/pull/1106) by [@yzx9](https://github.com/yzx9)) + +### 改进 + +- **过时的主机连接会自动恢复,无需手动刷新** +- Paseo 会打开到你上次使用时所在的工作区 ([#1101](https://github.com/getpaseo/paseo/pull/1101)) +- 工作区会记住你用哪个编辑器打开它们 +- 过时的 daemon 在收到无法理解的命令时会建议升级 +- Agent 运行时隐藏语音模式 +- Agent 文件链接的提示文字显示完整解析后的文件路径 ([#1088](https://github.com/getpaseo/paseo/pull/1088)) +- 工作区 git 状态在后台的刷新频率降低 ([#1102](https://github.com/getpaseo/paseo/pull/1102)) + +### 修复 + +- 显示器从睡眠中唤醒后 macOS 桌面端不再冻结 ([#745](https://github.com/getpaseo/paseo/pull/745)) +- Windows:Codex 能正确识别 Microsoft Store 安装 ([#1020](https://github.com/getpaseo/paseo/pull/1020) by [@32r4](https://github.com/32r4)) +- daemon 重启后工作区选择得以保留 ([#1111](https://github.com/getpaseo/paseo/pull/1111)) +- Cursor Agent 会等待斜杠命令加载完成后再列出它们 ([#1099](https://github.com/getpaseo/paseo/pull/1099) by [@chrisbanes](https://github.com/chrisbanes)) +- 遇到暂时性的子进程错误时 Codex 子 Agent 继续运行 (by [@xy-plus](https://github.com/xy-plus)) +- iPad 终端可以从硬件键盘正确发送 Ctrl+C (by [@samatar26](https://github.com/samatar26)) +- 包含非 ASCII 字符的 Git 文件名正确渲染 (by [@samatar26](https://github.com/samatar26)) +- 粘贴快捷键可以在 Dvorak 键盘布局上使用 (by [@qin-nz](https://github.com/qin-nz)) +- 对于路径需要 SDK 编码的项目,Claude 文件链接可以正确解析 +- 聊天中不再出现重复的 Claude 结果文本 ([#1095](https://github.com/getpaseo/paseo/pull/1095)) +- 动态 UI 样式不再把 CSS 规则泄漏到整个页面 ([#1103](https://github.com/getpaseo/paseo/pull/1103)) +- 中继握手会拒绝试图在中途更换加密密钥的会话 ([#1037](https://github.com/getpaseo/paseo/pull/1037) by [@joaosa](https://github.com/joaosa)) + +## 0.1.78 - 2026-05-18 + +### 改进 + +- **移动端模型选择器更快、更直接** 选择模型、模式或思考选项所需的点击更少 + +### 修复 + +- 拆分窗格不再丢失滚动位置 +- 在移动端底部面板中输入不再闪烁 +- 在移动端 Web 上滑动关闭底部面板不再崩溃 + +## 0.1.77 - 2026-05-18 + +### 新增 + +- **结束和重启 Agent 的斜杠命令** +- **聊天中代码块的语法高亮** +- **聊天中代码块的复制按钮** +- **可配置的终端回滚缓冲区** ([#1021](https://github.com/getpaseo/paseo/pull/1021) by [@32r4](https://github.com/32r4)) +- 助手文件链接包含行范围时会在该范围打开 +- Agent 状态菜单中显示模式图标 ([#1059](https://github.com/getpaseo/paseo/pull/1059) by [@32r4](https://github.com/32r4)) +- MCP 提供定时任务的更新、日志和单次运行工具 ([#1032](https://github.com/getpaseo/paseo/pull/1032) by [@skevetter](https://github.com/skevetter)) +- 自托管中继可以为公开端点使用不同的 TLS 设置 ([#1045](https://github.com/getpaseo/paseo/pull/1045) by [@yzx9](https://github.com/yzx9)) + +### 改进 + +- 用户消息现在有独特的气泡填充色,使聊天层次更清晰 +- 关闭标签页会回到其父标签页 +- 悬停时 diff 行显示完整文件路径 ([#1061](https://github.com/getpaseo/paseo/pull/1061) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin)) +- `ls` 无法连接时 CLI 会显示远程 daemon 主机 ([#1043](https://github.com/getpaseo/paseo/pull/1043) by [@mturac](https://github.com/mturac)) +- 通过 Nix 安装的 daemon 更小 ([#966](https://github.com/getpaseo/paseo/pull/966) by [@ixxie](https://github.com/ixxie)) +- 继承用户 PATH 时,Nix 安装会遵循 home-manager 配置路径 ([#1040](https://github.com/getpaseo/paseo/pull/1040) by [@ixxie](https://github.com/ixxie)) + +### 修复 + +- OpenCode 探测不再创建空会话 +- OpenCode 自定义命令不再挂起 +- OpenCode 会话导入在更多环境中都能成功 +- 原生 diff 行可以正确展开 ([#940](https://github.com/getpaseo/paseo/pull/940) by [@bolasblack](https://github.com/bolasblack)) +- Web 上的移动端侧边栏交互可以正常工作 ([#900](https://github.com/getpaseo/paseo/pull/900) by [@nikuscs](https://github.com/nikuscs)) +- 移动端 Web 的拖动手势可以可靠触发 ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs)) +- 移动端 Web 的拖放可以正确激活 ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs)) +- iOS Safari 聚焦 Composer 时不再缩放 ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs)) +- 移动端 Web Composer 中 Enter 的行为保持一致 ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs)) +- 带有长提示词时调整大小 Composer 不再闪烁 +- 助手消息中的行内代码链接打开正确的文件 +- 主机切换器弹出框足够宽,可以显示主机名称 ([#981](https://github.com/getpaseo/paseo/pull/981) by [@kongjiadongyuan](https://github.com/kongjiadongyuan)) +- Windows:导入已有会话时可以正确匹配路径 ([#1012](https://github.com/getpaseo/paseo/pull/1012) by [@kj1534](https://github.com/kj1534)) + +## 0.1.76 - 2026-05-15 + +### 新增 + +- **聊天时间戳和回合耗时** 每条消息都会显示发送时间,每个回合都会显示 Agent 用了多长时间 +- **Claude Code 和 Codex 的 Auto Review 权限模式** Agent 在每个助手回合之后停下等待审查,而不是无人值守地运行 ([#928](https://github.com/getpaseo/paseo/pull/928), [#963](https://github.com/getpaseo/paseo/pull/963) by [@bolasblack](https://github.com/bolasblack)) +- 在聊天中显示 Codex 的上下文压缩事件和 `/compact` 命令 +- 可选在 PR 合并后自动归档 worktree +- 把 GitHub PR 或 issue 的 URL 粘贴到 Composer 中即可将其作为上下文附加 +- 在 PR 悬停卡片中显示 GitHub 自动合并操作 +- 在 PR 悬停卡片中显示所有 PR 检查的数量 +- 可以重命名项目,以区分文件夹名称相同的重复项目 +- 归档有未提交或未推送工作的 worktree 之前会进行确认 +- Claude Code 现在会读取 `~/.claude/settings.json` 中的模型,因此自定义的模型列表会显示在模型选择器中 +- 本地的 Claude Code 设置(`.claude/settings.local.json`)按工作区生效 +- 通用 ACP Provider 的诊断信息显示在模型选择器中 +- 可以为 Paseo 子 Agent 设置 fast 模式 ([#909](https://github.com/getpaseo/paseo/pull/909), [#910](https://github.com/getpaseo/paseo/pull/910) by [@kongjiadongyuan](https://github.com/kongjiadongyuan)) + +### 改进 + +- 在聊天中显示 Claude 的错误消息,而不是静默结束回合 +- 只附加了一个 PR 时,工作区检出选择器会自动选中它 +- 从分支创建时,新工作区流程会遵循当前检出的分支 ([#909](https://github.com/getpaseo/paseo/pull/908) by [@sbtobb](https://github.com/sbtobb)) +- 来自控制台订阅 Provider 的 OpenCode 模型现在会出现在模型选择器中 ([#917](https://github.com/getpaseo/paseo/pull/917) by [@t2o2](https://github.com/t2o2)) +- Cursor 模型选择器反映 Cursor ACP 客户端提供的模型 ([#958](https://github.com/getpaseo/paseo/pull/958) by [@chrisbanes](https://github.com/chrisbanes)) + +### 修复 + +- iPad 硬件键盘的 Enter 可以提交 Composer ([#919](https://github.com/getpaseo/paseo/pull/919) by [@kongjiadongyuan](https://github.com/kongjiadongyuan)) +- 对于细粒度 GitHub 令牌,PR 状态会回退到不含检查的查询 ([#932](https://github.com/getpaseo/paseo/pull/932) by [@32r4](https://github.com/32r4)) +- ACP 错误显示为可读文本,而不是 `[object Object]` +- 上游 Provider 停滞时 OpenCode 在重试时不再挂起 +- 上游分支被删除后 worktree 的领先提交数正确 +- 从分支创建的 worktree 跟踪正确的上游 +- 文件更改视图可以在还没有提交的空仓库中使用 +- 助手消息中的文件链接打开正确的文件 +- 默认的思考选项与所选模型的能力相匹配 +- Shift+Enter 在终端输入模式中恢复正常 +- 重新打开项目后不再出现重复的项目条目 +- 由 Pi 支持的会话在遇到 Copilot 413 后可以恢复,而不是一直卡住 +- 启动 Agent 时跳过探测无关的可执行文件候选 +- 连接和断开竞争时中继的 E2EE 可以干净地重新连接 +- 重新配置后工作区类型与项目类型保持同步 +- zsh 集成文件安装时带有可用的运行时模式 +- 创建和归档后 MCP worktree 缓存会刷新 ([#911](https://github.com/getpaseo/paseo/pull/911) by [@kongjiadongyuan](https://github.com/kongjiadongyuan)) + +## 0.1.75 - 2026-05-12 + +### 新增 + +- 可以在设置中设置语音输入和语音模式使用的语音转文字语言 ([#941](https://github.com/getpaseo/paseo/pull/941)) + +### 修复 + +- Codex 恢复失败现在会以明确的错误显示,而不是让 Agent 静默卡住 ([#947](https://github.com/getpaseo/paseo/pull/947)) +- 设置了自定义 `OPENAI_BASE_URL` 的、扩展自 Codex 的自定义 Provider 现在可以正确路由 ([#915](https://github.com/getpaseo/paseo/pull/915)) +- 修复了 Copilot 的 **Allow All** 模式(由 Autopilot 更名而来) ([#935](https://github.com/getpaseo/paseo/pull/935)) +- 桌面端:仍在运行的 daemon 旁边留有过时的 PID 文件时,daemon 启动不再失败 ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty)) +- 可以从图片选择器正确附加 iPhone 的 HEIC 照片 ([#934](https://github.com/getpaseo/paseo/pull/934)) +- 定时任务的 Agent 现在会在每次运行后自动归档 ([#945](https://github.com/getpaseo/paseo/pull/945)) +- Windows:Codex 命令摘要会去掉 `pwsh`、`powershell` 或 `cmd` 包装 ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4)) +- iPad:在宽布局中,设置侧边栏和主侧边栏遵循顶部安全区域 ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan)) + +## 0.1.74 - 2026-05-11 + +### 修复 + +- **OpenCode Agent 的回合不再停滞** Paseo 现在跟随 OpenCode 的全局事件流,因此回合能可靠地流式输出,而不会退回到脆弱的恢复路径 ([#916](https://github.com/getpaseo/paseo/pull/916)) + +## 0.1.73 - 2026-05-10 + +### 修复 + +- **OpenCode Agent 在 OpenCode 1.14.42+ 上恢复可用** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog)) +- Web:在不支持 `crypto.randomUUID` 的浏览器中打开工作区不再挂起 ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten)) +- Codex 子 Agent 的子工具调用现在会报告最终的失败状态,而不是一直显示“运行中” ([#899](https://github.com/getpaseo/paseo/pull/899)) +- 没有显式 TLS 标志的旧中继配对 URL 恢复可用 ([#896](https://github.com/getpaseo/paseo/pull/896)) +- macOS:标签页跳转快捷键不再与系统快捷键冲突 ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs)) +- Web:在桌面浏览器上 Composer 不再触发底部面板键盘 ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs)) +- Windows:git 操作不再在每次调用时闪出控制台窗口 ([#897](https://github.com/getpaseo/paseo/pull/897)) +- 文件浏览器不再跟随指向工作区根目录之外的符号链接 ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa)) +- 桌面端只通过 http(s) 和 mailto 协议打开外部 URL ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa)) +- MCP 调试请求日志现在会隐去请求正文 ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa)) + +## 0.1.72 - 2026-05-10 + +### 修复 + +- **Codex 审批提示不再挂起** 修复了 0.1.70 引入的回归:Codex Agent 会在命令和文件审批上永远等待——提示从未到达应用,Agent 一直卡在“运行中” ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869)) +- **Windows:Codex 输出非 JSON 内容时 daemon 不再崩溃** 来自 Codex CLI 的本地化 stdout 行现在会被忽略,而不会拖垮 daemon 工作进程 ([#866](https://github.com/getpaseo/paseo/pull/866)) +- 现在可以把图片拖放到新建工作区屏幕上 ([#850](https://github.com/getpaseo/paseo/pull/850)) +- 从工具栏归档 worktree 会立即跳转,而不会让你在失效的屏幕上停留片刻 ([#852](https://github.com/getpaseo/paseo/pull/852)) +- 由 Pi 支持的会话在关闭时会干净地退出,并释放 Pi 端的扩展资源 ([#863](https://github.com/getpaseo/paseo/pull/863)) + +## 0.1.71 - 2026-05-09 + +### 新增 + +- **把已有的 Claude、Codex 和 OpenCode 会话导入** Paseo——在终端中开始的对话可以在应用中继续,并保留完整的时间线 ([#766](https://github.com/getpaseo/paseo/pull/766), [#833](https://github.com/getpaseo/paseo/pull/833)) +- **子 Agent 现在显示在 Composer 上方的可折叠区域中**,方便你跳转到主 Agent 创建的 Agent ([#532](https://github.com/getpaseo/paseo/pull/532)) +- 直接从检出窗格合并拉取请求 ([#814](https://github.com/getpaseo/paseo/pull/814)) +- 自定义 Paseo 按项目用于自动生成 Agent 标题、分支名、提交信息和拉取请求描述的提示词 ([#836](https://github.com/getpaseo/paseo/pull/836)) +- 无需先输入提示词即可打开空工作区 ([#834](https://github.com/getpaseo/paseo/pull/834)) +- 项目设置现在分组显示,并带有指向相关文档的行内链接 ([#837](https://github.com/getpaseo/paseo/pull/837)) +- 桌面端提供丰富的上下文菜单——复制链接、复制图片和拼写检查建议 +- 归档由 Codex 支持的 Agent 现在也会归档底层的原生 Codex 线程 ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4)) + +### 改进 + +- 打开工作区时会自动聚焦需要你关注的 Agent ([#828](https://github.com/getpaseo/paseo/pull/828)) +- 无人值守的 Agent 通过 MCP 在其他 Provider 上创建子 Agent 时,子 Agent 现在也以无人值守模式启动 + +### 修复 + +- iOS 项目选择器现在会提交输入的路径 ([#831](https://github.com/getpaseo/paseo/pull/831)) +- 发往多个 Agent 的系统消息和聊天提及现在能一致地送达每个接收方 ([#830](https://github.com/getpaseo/paseo/pull/830)) +- 点击 Agent 输出中的 Markdown 链接时,除了打开链接,不再同时重新加载桌面应用 +- macOS 桌面端标签页跳转快捷键现在使用 Cmd+Option+1-9,避免与基于 Option 的国际键盘字符(如 `@`)冲突 + +### 安全 + +- 本地状态文件(daemon 密钥对、存储的凭据、持久化的配置)现在只有所属用户可读 ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa)) + +## 0.1.70 - 2026-05-08 + +### 破坏性变更 + +- **Claude Agent 现在要求 `claude` 位于 PATH 中** 运行 Claude Agent 之前请全局安装 Claude Code(`npm install -g @anthropic-ai/claude-code`)——Paseo 不再附带内置的回退二进制。这与 Codex 和 OpenCode 的做法一致,并让每个平台的桌面安装包缩小约 210 MB + +### 新增 + +- **一键添加 ACP Provider**——从内置目录添加 Cursor、Hermes、Qwen Coder、Kimi Code 等 ACP Agent,无需手写配置 +- Codex `/goal` 斜杠命令——在 Codex Agent 运行时于回合中途设置或更新目标 +- 模型选择器中现在可以选择 Claude 的 Sonnet 4.6 1M 上下文模型 +- 识别粘贴到 Composer 搜索中的 GitHub issue 和 PR URL +- `paseo worktree create` CLI 命令,功能与 MCP `create_worktree` 工具对等 +- `paseo schedule update` 可以原地编辑定时任务,无需重新创建 +- 用于 cron 式触发的 `paseo schedule run-once`,以及 `schedule` 和 `loop` 上的 `--mode`。后台运行现在默认使用无人值守模式 +- 项目设置现在列出来自任意远程仓库的工作区——GitLab、Gitea、Bitbucket、自托管和 SSH 风格的 URL,而不只是 GitHub ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid)) + +### 改进 + +- 技能现在按需安装、更新和卸载,而不是在每次桌面端启动时静默自动同步 +- 自托管中继可以选择为 TLS 连接使用 `wss://` +- 工作区打开目标只显示当前 daemon 可访问的选项 +- 组合框搜索会匹配模型描述,而不只是名称 +- Codex 图片附件以路径 markdown 的形式行内渲染 +- 子 Agent 任务通知不再干扰父 Agent 的时间线 +- 语音模式:思考提示音更轻,界面细节更打磨 +- 设置侧边栏顺序:Projects 现在位于 General 之后 +- 桌面应用的 Electron 升级到 41.2.0 + +### 修复 + +- **Claude Agent:daemon 不再在回合中途崩溃**,即使底层 SDK 在连接断开后发出了多余的控制消息 +- **Windows:** 终端可以可靠地启动并干净地关闭,不会留下卡住的进程 +- **Linux:** 在繁忙的工作树上,工作区文件监视器不再产生事件风暴,修复了大型仓库上的 CPU 峰值 ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105)) +- 基于 ACP 的 Agent 可以可靠地启动终端 shell 命令 ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223)) +- 检出的简要统计现在会计入未跟踪的文件 ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus)) +- 端口 443 上的中继端点会自动使用 TLS ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer)) +- 桌面端 CLI 透传的 TTY 处理——从桌面应用启动的交互式命令现在行为正确 +- CLI 会遵循 `PASEO_PASSWORD` 环境变量来连接受密码保护的 daemon +- daemon 关闭时使用 tree-kill 干净地终止所有子进程 +- Agent 启动路径能更可靠地处理缺失的可执行文件和非常规的安装布局 +- OpenCode 现在会转发 Provider 的重试错误,而不是静默吞掉 +- Codex 导入不再回退到错误的默认模式 +- 在可编辑字段中输入时,窗格键盘快捷键不再触发 +- 在 Web 上冷启动导航到工作区 URL 时,现在会落在正确的侧边栏条目上 +- 修复了 Web 上的工作区导航回归 +- 消除了重复的工作区外壳导航 +- “Update installed”提示不再错误地闪现 +- 浏览器窗格的重新加载焦点和开发者工具处理 +- MCP 终端捕获现在包含回滚缓冲区 +- 通过 MCP 针对已有 worktree 创建 Agent 时,worktree 分支不再被重命名 +- 在已注册工作区的子目录中创建 Agent 现在会在该子目录中运行,而不是跳到父目录 ([#551](https://github.com/getpaseo/paseo/issues/551)) +- 非 GitHub 项目的显示名称取自远程仓库的 owner/repo,而不是本地路径 +- 桌面端 IPC 封装进共享的 mutation/query hook,修复了过时状态和间歇性失败 ([#761](https://github.com/getpaseo/paseo/issues/761)) +- `paseo schedule create --host` 现在要求 `--cwd`,以免在错误的目录中运行定时任务 +- `paseo schedule create --every` 默认立即运行一次,之后按配置的间隔运行 +- MCP `create_agent` 会校验所请求的模式,并拒绝静默的跨 Provider 继承 + +## 0.1.69 - 2026-05-05 + +### 修复 + +- 内部 daemon 进程崩溃时 Paseo 现在会自动恢复——你的 Agent 保持连接而不会卡住,你也无需重启任何东西 +- 回答 Claude Agent 的交互式问题现在能正确送达 Claude,而不会被丢弃 ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus)) + +## 0.1.68 - 2026-05-05 + +### 修复 + +- 全新安装后首次启动桌面应用不再失败 + +## 0.1.67 - 2026-05-03 + +### 修复 + +- 归档 worktree 或工作区立即生效,无需等待 daemon,失败时会自动回滚 +- 桌面设置中的内置 daemon 开关现在真正生效 +- 旧版迁移后,桌面设置不再在应用启动时被重置 +- 桌面 daemon 启动失败现在会显示在启动屏幕上并响应重试,而不是让应用静默卡住 +- 内部 LLM 调用(分支名、提交信息、PR 文本)不再在你的 Provider 历史中留下临时的 Agent 会话 + +## 0.1.66 - 2026-05-03 + +### 修复 + +- 流式 markdown 会保留末尾的换行,因此在 Agent 仍在输出时段落间距保持正确 +- Agent 初始化失败会在 30 秒内显示,而不是 5 分钟 +- 终端会回复 ANSI 光标位置查询,因此询问光标位置的工具不再挂起 + +## 0.1.65 - 2026-05-03 + +### 新增 + +- **应用内浏览器**——在任意工作区中打开真正的 Web 浏览器来测试你的应用 ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen)) +- git diff 窗格中的行内审查评论。点击行号即可开始评论 ([#530](https://github.com/getpaseo/paseo/pull/530)) +- 现在会显示 Codex、OpenCode 和 Claude 的子 Agent 活动 ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift)) +- 在更改窗格的 git 操作菜单中一步完成分支的拉取和推送 +- 使用 `paseo import --provider ` 恢复已有的 Agent 会话 ([#632](https://github.com/getpaseo/paseo/pull/632)) +- daemon 连接支持密码认证和 SSL ([#635](https://github.com/getpaseo/paseo/pull/635)) +- 在 CLI 中使用配对邀请 URL 通过中继连接到 daemon ([#639](https://github.com/getpaseo/paseo/pull/639)) +- **Windows:** 现在提供原生 ARM64 构建 +- 内置的 Paseo 技能现在会在桌面应用启动时自动刷新 + +### 改进 + +- Codex 流式输出响应更快——保留消息边界,输出更早到达 +- 终端会话在专用的工作进程中运行,稳定性更好 +- 新 worktree 的分支名取自你的提示词和附件,而不是通用占位符 +- 审查评论界面更整洁、更易浏览 +- 配置了密码时,daemon 的 `/api/status` 端点现在受密码认证保护 + +### 修复 + +- **Apple Silicon Mac:** 桌面更新流水线现在以原子方式发布清单,消除了可能在 Apple Silicon Mac 上安装 Intel 构建并导致渲染进程 CPU 占用超过 100% 的竞态。受影响的用户会自动恢复——electron-updater 的 Rosetta 检测会在下一次检查更新时迁回 arm64 ([#555](https://github.com/getpaseo/paseo/issues/555)) +- **Linux:** `.deb` 和 `.rpm` 包现在在程序坞和进程列表中显示为 `Paseo`,而不是 `Paseo.bin`。`--no-sandbox` 现在只作用于 AppImage,与 VS Code 的沙箱处理一致 ([#602](https://github.com/getpaseo/paseo/issues/602)) +- **Windows:** Git diff 命令遇到包含特殊字符的路径不再出错 ([#629](https://github.com/getpaseo/paseo/pull/629)) +- Cursor CLI 和其他 ACP 自定义 Provider 可以可靠地启动 ([#628](https://github.com/getpaseo/paseo/pull/628)) +- WebSocket 客户端在流中途断开时 daemon 保持运行,崩溃时现在会写入一条致命日志,而不是静默消失 ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz)) +- 长 Agent 时间线可以通过中继干净地重新连接,而不会在追赶时反复断开 ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue)) +- 重新打开 Agent 时,Agent 时间线以更小的追赶请求刷新 +- 终端快照在客户端重新连接之前可靠地刷新 +- 当聚焦的工作区已是最新时,工作区重新连接会避免不必要的刷新工作 +- Agent 标签页失去焦点时语音输入继续录音 +- OpenCode 模式选择器现在列出所有模式中可用的 Agent ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift)) +- Codex 计划审批面板不再重复 +- 导入的 Agent 会立即显示正确的标题 +- OpenCode 会显示无效的模式或模型错误,而不是挂起 +- 已归档的 worktree 保持隐藏,不会闪回列表 ([#640](https://github.com/getpaseo/paseo/pull/640)) +- Web 下拉菜单不再意外改变大小 +- 可见的更改窗格与工作树的 diff 保持同步 +- 时间线上的工具详情行恢复可选择 +- setup、teardown 和终端操作中的 `paseo.json` 解析错误现在会显示明确的错误,而不是静默失败 +- 修复了 Web 上 diff 行号栏在某些情况下错位一行的问题 +- 在回合中途加载时间线时,流式的 Agent 输出可以干净地对齐 ([#663](https://github.com/getpaseo/paseo/pull/663)) +- 助手消息中的图片在加载时显示加载指示器,加载失败时显示“Image unavailable”回退,而不是一片空白 +- 隔离的底部面板模态框可以关闭并重新打开,不会卡住 + +## 0.1.64 - 2026-04-28 + +### 新增 + +- OpenCode 现在有自动批准工具调用的 Full Access 模式 ([#595](https://github.com/getpaseo/paseo/pull/595) by [@tmih06](https://github.com/tmih06)) +- OpenCode 支持可执行的斜杠命令 ([#597](https://github.com/getpaseo/paseo/pull/597) by [@tmih06](https://github.com/tmih06)) + +### 改进 + +- 在非常大的项目中 `@` 提及保持流畅 ([#600](https://github.com/getpaseo/paseo/pull/600) by [@yuruiz](https://github.com/yuruiz)) + +### 修复 + +- `paseo.json` 存在解析错误时工作区仍能加载 + +## 0.1.63 - 2026-04-28 + +### 新增 + +- 带有内置 `paseo.json` 编辑器的项目设置页面 +- 冷启动会恢复你上次打开的工作区 +- 工具调用徽标上有一个按钮,可直接打开所引用的文件 +- 从工作区的打开菜单中在 GitHub 上打开当前分支 ([#583](https://github.com/getpaseo/paseo/pull/583) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin)) +- 在 Settings 中启用或停用 Provider,无需编辑配置文件 +- 缺少 worktree setup 脚本时,Paseo 会提示你进行配置 +- 选择关闭桌面应用时是否关闭 daemon + +### 改进 + +- 重新设计了 Provider 设置和模型选择 +- 对于兼容 OpenAI 的 Provider,可以配置语音模式的转写端点 ([#570](https://github.com/getpaseo/paseo/pull/570) by [@yuruiz](https://github.com/yuruiz)) +- 添加项目不再等待 GitHub PR 状态加载 +- 启动屏幕更简洁——只有 logo 和一道淡淡的微光 +- `paseo.json` 的 setup 和 teardown 接受单个命令字符串,而不只是数组 +- 归档 worktree 立即生效,无需等待后端确认 +- 加载或流式输出时,Agent 时间线和 git diff 列表不再跳动 + +### 修复 + +- `paseo loop run` 和 `paseo run` 现在会遵循 `--provider` 和 `--model` 标志 ([#594](https://github.com/getpaseo/paseo/pull/594) by [@VincenzoRocchi](https://github.com/VincenzoRocchi)) +- 只设置了 DeepSeek 或其他非 OpenAI/Anthropic/OpenRouter 的 API 密钥时,Pi Provider 也会显示 +- 为新 Agent 选择默认模型时,会考虑来自 `additionalModels` 和 `profileModels` 的自定义模型 +- 文件预览的行号在第 99 行之后仍保持在一行内 +- 在 macOS 上 Cmd+Q 会退出桌面应用,而不是让它在后台继续运行 +- 渲染出现问题后终端会话可以干净地恢复,包括 nvim 的初始尺寸调整 +- 终端协议查询的响应不再泄漏到浏览器中 +- 助手链接颜色重新与主题一致 +- 带行号的文件链接(如 `foo.ts:42`)可以从助手消息中正确打开 +- Claude 的 Grep 结果会显示在搜索详情正文中 +- 重新打开 worktree 会落在正确的项目下 +- 来自已停用或不可用 Provider 的 Agent 在历史中保持可见 +- 新的 CLI Agent 现在要求指定 Provider,而不是静默失败 +- Git diff 标题不再被截断 +- Provider 诊断模态框在矮屏幕上可以滚动 +- Provider 诊断显示真实的错误和底层子进程输出,而不是通用消息 +- 已归档的工作区不再干扰工作目录解析 +- 在消息上三击不再把选区扩展到相邻的气泡 +- 打包后的桌面应用会保留你的 zsh 提示符 + +## 0.1.62 - 2026-04-23 + +### 新增 + +- 应用与 daemon 版本不一致时侧边栏会显示警告,并提供前往设置的快捷方式 + +### 改进 + +- 工作区在启动时立即显示在侧边栏中,无需等待 git 注册 + +### 修复 + +- 从 fork 打开的 PR 的拉取请求状态可以正确解析 +- 在打包构建中,从 macOS 桌面应用安装 paseo CLI 现在可以正常工作 +- 从桌面应用启动的 Agent 不再继承仅限 Electron 的环境变量 + +## 0.1.61 - 2026-04-23 + +### 新增 + +- Provider 配置中的 `additionalModels` 选项可以添加模型或重新命名模型,而无需替换整个列表——条目会与运行时发现的模型(ACP)或你的静态 `models` 列表合并。参见 [Provider 文档](https://paseo.sh/docs/providers) +- 新的 [Provider 文档页面](https://paseo.sh/docs/providers),在一处涵盖一流 Provider 和所有自定义 Provider 的配置模式 + +### 改进 + +- Pi 在启动时会加载你已安装的扩展,因此其模型会显示在模型选择器中 +- 调整资源管理器侧边栏大小不再重新渲染工作区的其余部分 +- 助手消息中的图片(文件路径和行内 data URL)会作为本地附件保存,并在文件窗格中打开 + +## 0.1.60 - 2026-04-22 + +### 新增 + +- 每个 worktree 的脚本和服务——在 `paseo.json` 中定义命名命令,长时间运行的服务会被托管,拥有自己的端口和像 `http://web.my-app.localhost:6767` 这样友好的代理 URL。参见 [worktree 指南](https://paseo.sh/docs/worktrees) +- 直接从工作区标题栏为 worktree 启动脚本和服务 +- 每个工作区中新增 Setup 标签页,实时显示 setup、teardown 和脚本进度 +- 资源管理器侧边栏中显示 GitHub 检查和 PR 审查,并提供展示完整明细的悬停卡片 +- 新的 worktree 创建流程可以选择基础分支,或检出已有的 GitHub 拉取请求 +- 把 GitHub issue 和拉取请求作为提示词上下文的一部分附加给 Agent +- 工作区侧边栏中的拉取请求窗格 +- 重新设计的 Settings 屏幕,采用模块化的分区导航 +- 按主机配置 Provider——在每台远程主机上独立设置 Provider、模型和凭据 +- 直接集成 Pi,取代 ACP 桥接,流式输出更快、卡顿更少 +- Beta 发布渠道——在 Settings 中选择加入,即可在桌面 beta 构建升级为稳定版之前收到它们 +- 新建工作区选择器按最近使用对分支排序,并支持快速搜索 + +### 改进 + +- 在桌面端和移动端,工作区和标签页切换大幅加快——你可以并行打开许多工作区而不会卡顿 +- 在大量工具输出期间 Agent 流渲染更流畅 +- 应用启动经由稳定的连接,并在不闪烁的情况下落在正确的屏幕上 +- Provider 刷新更可靠,不再因暂时性故障而停滞 +- Git 和 GitHub 状态与提交、切换分支和推送等本地更改保持同步 +- 重新设计了 Composer 附件,采用更简洁的胶囊布局和图片灯箱 +- 应用内通知会发送到你实际正在查看的界面 +- 打开 Settings 时键盘快捷键仍然可用 +- Escape 可以可靠地中断当前 Agent +- 检出来自 fork 的拉取请求会落在带 owner 前缀的分支上,避免多个 fork 冲突 +- `paseo ls` 默认显示活跃的 Agent;传入 `-a` 可包含已归档的 Agent +- GitHub 分支和 PR 选择器加载更快——查询会推迟到选择器打开时 + +### 修复 + +- 在 Web 上发送后 Composer 文本框会缩回原来的大小 +- 新工作区草稿在提交后被清除,而不会一直留着 +- 替换运行中的 Agent 时会清理之前的 Agent,而不会把它留下 +- Agent 通知不再被处于后台的已聚焦客户端吞掉 +- 已移除的工作区文件夹重新从工作区列表中消失 +- 批准计划后 Codex 保持 fast 模式 ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme)) +- 工作区标签页焦点在页面刷新后得以保留 +- Settings 屏幕不再因多余的间距把标题栏往下推 +- 分支切换器的标题在窄行中不再溢出 +- 取消后 iOS 图片选择器不再让屏幕失去响应 +- 如果之前的尝试被中断,归档 worktree 可以干净地恢复 +- Agent 消息中以 `~` 开头的路径的图片可以加载,而不会一直转圈 +- Agent 仍在流式输出时,移动端的工具调用块可以正确展开 +- 追赶范围与投影范围重叠时时间线不再卡顿 +- 替换回合正在进行时 Codex 不再闪现空闲状态 +- rebase 进行中时分支状态可以正确恢复 +- 工作区悬停卡片在屏幕边缘附近不再被裁切 + +## 0.1.59 - 2026-04-16 + +### 新增 + +- Claude 模型选择器中新增 Opus 4.7,并提供 1M 上下文的变体 +- Opus 4.7 新增 Extra High 推理强度,介于 High 和 Max 之间 + +## 0.1.58 - 2026-04-16 + +### 新增 + +- Markdown 文件在文件窗格中渲染为格式化的 markdown ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey)) +- Cmd+L(Windows/Linux 上为 Ctrl+L)聚焦 Agent 消息输入框 +- Provider 模型按新鲜度 TTL 刷新;Settings 显示上次更新时间和所有获取错误 ([#426](https://github.com/getpaseo/paseo/pull/426)) +- Provider 配置中的 `disallowedTools` 选项,可以禁止 Agent 使用特定工具 + +### 改进 + +- Windows:Agent 可以从 npm `.cmd` 包装脚本、带空格的路径和 JSON 配置参数可靠地启动——修复了 `spawn EINVAL` 启动错误 ([#454](https://github.com/getpaseo/paseo/pull/454)) +- OpenCode 权限提示包含发起请求的工具的上下文 ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey)) +- OpenCode 的待办和压缩事件会在时间线中渲染 ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey)) +- OpenCode 会话在关闭时干净地归档 ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey)) +- OpenCode 斜杠命令可以从 SSE 超时中恢复 ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey)) +- Paseo MCP 工具可以作用于已归档的 Agent,与 CLI 一致 ([#423](https://github.com/getpaseo/paseo/pull/423)) +- 在所有 Web 视图中,原生滚动条与当前主题一致 ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh)) + +### 修复 + +- 在 iOS 上可以选择和复制代码文件预览 ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991)) +- 重新打开同一文件时,文件预览不再显示过时的内容 ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991)) +- 页面刷新后客户端重新连接时,文件浏览器会重新初始化 ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui)) +- 通用 ACP Provider 不再收到重复的命令参数 ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen)) +- 非 git 工作区的标题栏不再显示分支图标 +- 移动端分支切换器的布局保持稳定 +- 选择器行中的模型名称不再在单词中间截断 +- 在移动端重新连接后消息按正确顺序显示 +- 清除 Agent 提醒时不再因超时而抛出错误 + +## 0.1.56 - 2026-04-14 + +### 修复 + +- 包含空 git 仓库(尚无提交)的项目不再导致应用在启动时崩溃 +- 单个有问题的项目不再阻止其余工作区加载 + +## 0.1.55 - 2026-04-14 + +### 新增 + +- Provider 配置档——在 Paseo 配置中定义自定义 Provider,它们会与内置 Provider 一起显示。可以覆盖内置 Provider 的二进制、环境变量或模型,也可以创建全新的 Provider。参见[配置指南](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md) +- 支持 ACP Agent——在 Provider 配置中使用 `extends: "acp"` 即可把任何兼容 ACP 的 Agent 添加到 Paseo,无需修改代码 +- 创建定时任务 Agent 时可以选择 Provider 和模型 +- Opus 4.6 模型新增 Max 推理强度选项 +- Cmd+,(Windows/Linux 上为 Ctrl+,)打开设置 + +### 改进 + +- Git 操作大幅加快——工作区状态、PR 检查和分支数据都使用共享的缓存快照服务,而不是在每次请求时调用 git。同时运行 20 多个工作区现在也很流畅 +- 支持 Windows——daemon 和 CLI 在 Windows 上原生运行,具备正确的 shell 引号、可执行文件解析和路径处理 +- iPad 和平板布局在所有屏幕尺寸下都能正确显示 +- 按 Enter 时 IME 组字(中文、日文、韩文输入)不再提前提交 + +### 修复 + +- 创建 worktree 时不再短暂地把它显示为独立项目,然后才放到正确的仓库下 +- worktree 创建的加载指示器在整个过程中保持可见,而不会在鼠标移开时消失 +- 在同一项目的工作区之间切换时,工作区导航可以正确更新 +- 在窄窗口中,桌面端工作区标题栏的对齐和模型选择器不再溢出 +- 加载指示器在浅色模式下可见 + +## 0.1.54 - 2026-04-12 + +### 新增 + +- Agent 消息中的行内图片预览——Agent 生成的截图和图片直接在对话中渲染,而不是显示为原始的 markdown 链接 + +### 改进 + +- 默认不再向 Agent 注入 Paseo 工具——需要 Agent 之间的编排时可在 Settings 中选择开启 +- Agent 的 Provider 和模式现在在服务端解析,因此 `paseo run` 等 CLI 命令无需在客户端查找即可使用一致的默认值 + +### 修复 + +- Shift+Enter 现在会在 Agent 终端输入中正确插入换行,而不是提交 +- Windows:创建 Claude Agent 时 MCP 配置不再被破坏 +- 没有远程跟踪分支的分支不再在计算领先/落后数时出错 + +## 0.1.53 - 2026-04-12 + +### 新增 + +- Agent 自动获得 Paseo 工具——每个新 Agent 都能通过 MCP 访问终端、定时任务、worktree 和其他 Agent。可在 Settings 的“Inject Paseo tools”中关闭 +- Git pull——直接从工作区标题栏拉取远程更改。当你的分支落后于 origin 时会提升为主要操作 +- 子 Agent 通知——子 Agent 完成、出错或需要权限审批时会自动通知父 Agent +- 重新加载 Agent——`paseo agent reload` 可以从 CLI 重启 Agent 的底层进程 +- 在桌面端用鼠标中键关闭标签页 +- 循环切换主题的键盘快捷键 + +### 改进 + +- 不可用的 git 操作现在会在提示中说明原因,而不是静默变灰 +- 移动端的流式 markdown 渲染明显加快 +- 侧边栏、分支切换器和 Agent 面板不再进行不必要的重新渲染——在大型工作区上效果明显 +- Agent 时间线中的 Paseo 工具调用显示 Paseo logo 和易读的名称 +- 中继和配对 URL 会从 daemon 日志中去除 + +### 修复 + +- 已关闭的 Agent 标签页在重新连接后不再重新出现 +- 桌面通知徽标计数在所有工作区之间保持一致 +- 在主机之间切换时主机切换器的状态可以正确同步 + +## 0.1.52 - 2026-04-10 + +### 新增 + +- 主题选择器——从六种主题中选择,包括 Midnight、Claude 和 Ghostty 深色变体 +- 切换分支——直接从工作区标题栏切换 git 分支,未提交的更改会自动 stash 并恢复 +- 自动下载更新——桌面更新会在后台静默下载,你准备好时即可安装 + +### 修复 + +- 调整窗口大小或旋转平板时布局现在能正确响应——之前应用可能在大屏幕上卡在移动端布局 +- 在大量输出期间,终端不再因快照频繁抖动而导致内存暴涨 +- 在终端中输入可以可靠地工作——特殊按键、Ctrl 组合键和粘贴都由终端模拟器原生处理 +- 初始化中的 Agent 不再像在运行一样显示加载指示器 +- 即使会话持久化不可用,也可以重新连接到正在运行的 Agent +- 桌面端的错误屏幕现在可以滚动 +- 打开模型选择器时模型列表会在后台刷新 +- 草稿 Agent 的功能偏好(如思考模式)会跨会话记住 + +## 0.1.51 - 2026-04-09 + +### 新增 + +- OpenCode 支持图片附件——在 OpenCode Agent 的提示词中附加截图和图片 +- WebStorm——加入“Open in editor”列表,与 Cursor、VS Code 和 Zed 并列 +- 发送行为设置——选择在 Agent 运行时按 Enter 是立即中断,还是把你的消息排入队列 + +### 修复 + +- 模型选择器在 iPad 上不再崩溃 +- 配对现在使用正确的主机名,修复了某些网络环境下的连接失败 +- OpenCode Agent 显示正确的终止状态,并能可靠地刷新模型 +- 现在可以正确地向刚完成一个回合的 Agent 发送后续消息 +- Pi Agent 的命令现在可以正确加载 +- 内部调试输出不再出现在 Claude Agent 的时间线中 +- 简化了二维码扫描屏幕的视觉效果 + +## 0.1.50 - 2026-04-07 + +### 新增 + +- 上下文窗口用量表——查看 Agent 已使用了多少上下文窗口,并在 70% 和 90% 处有颜色阈值。适用于 Claude Code、Codex 和 OpenCode +- 在编辑器中打开——从任意工作区直接跳转到 Cursor、VS Code、Zed 或文件管理器。Paseo 会记住你的选择 +- 并排 diff——在统一视图和分栏 diff 视图之间切换,并可选择是否显示空白字符 +- 语音消息——使用语音模式时,Agent 的语音现在作为普通消息出现在对话中,而不是原始工具输出 +- 计划操作——计划卡片现在显示 Agent 支持的操作(例如“Implement”“Deny”),而不是通用的接受/拒绝按钮 +- 后台 git fetch——Changes 窗格中的领先/落后计数会自动保持最新 + +### 改进 + +- 连接时工作区立即加载,无需等待完整同步 +- 切换标签页时,文件浏览器和 diff 窗格会记住哪些文件夹已展开 +- 关闭工作区标签页现在立即生效 +- Settings 为 Provider 显示 Refresh 按钮,并行内显示错误详情 +- 重新加载 Agent 的按钮移到远离关闭按钮的位置,以防误触 + +### 修复 + +- 在长时间的会话中,语音模式不再逐渐误检测到语音 +- 计划卡片上文字重叠错乱 +- 使用 git worktree 时,Changes 窗格可能显示过时的 diff +- 快速重启 Agent 可能导致会话崩溃 +- 在 autopilot 模式下 Copilot 不再因权限提示而暂停 +- 连接和配对对话框现在在平板上正确显示 +- Agent 的编排错误现在会显示出来,而不是静默丢失 +- 重新连接时 diff 统计不再归零 + +## 0.1.49 - 2026-04-07 + +### 修复 + +- 模型和 Provider 现在在首次连接时可靠地加载,无需手动刷新 +- 模型选择器只显示 Agent 自身 Provider 的模型,而不是服务器上所有 Provider 的模型 +- 无论先打开哪个屏幕,模型列表都保持一致 + +## 0.1.48 - 2026-04-05 + +### 新增 + +- Provider 诊断——在 Settings 中点击某个 Provider,即可一目了然地查看二进制路径、版本、模型数量和状态。有助于排查某种 Agent 类型为何不可用 +- Provider 快照系统——daemon 现在会把实时的 Provider 可用性和模型列表推送给应用,取代旧的轮询方式。Provider 上线或下线时,模型和模式会实时更新 +- Codex 问题处理——Codex Agent 现在可以在会话中途向用户提问(例如“哪个文件?”)并行内接收回答,与 Claude Code 的问题流程一致 +- 重新加载标签页操作——右键点击工作区标签页即可重新加载其 Agent 列表,无需重启应用 + +### 改进 + +- 重新设计了模型选择器——按 Provider 分组并带有状态徽标、搜索,以及移动端更好的触控目标 +- Enter 键现在可以提交问题卡片的回答并确认语音输入,符合预期的键盘流程 +- 移除了在每次状态变化时都会弹出的嘈杂的 Agent 生命周期提示 + +### 修复 + +- 桌面应用现在会在启动时解析用户完整的登录 shell 环境,修复了从 Finder 或 Dock 启动 Paseo 时找不到 `codex`、`node`、`bun` 和 `direnv` 等工具的问题。由 Paseo 创建的终端现在继承与普通终端会话相同的 PATH 和环境变量。做法借鉴自 VS Code 久经考验的 shell 环境解析 +- 运行中的 Agent 屏幕上的输入框现在可以正确获得键盘焦点 +- 移动端模型选择器的对齐和尺寸 + +## 0.1.47 - 2026-04-05 + +### 修复 + +- Electron 中的语音 TTS——sherpa 现在请求复制的缓冲区,语音 MCP 桥接会设置 `ELECTRON_RUN_AS_NODE`,避免出现“external buffers not allowed”崩溃 +- 桌面端的二维码配对——CLI JSON 输出解析现在能容忍 stdout 中的 Node 弃用警告 +- STT 片段竞态——在异步转写调用之前对片段 ID 和音频缓冲区做快照,因此快速提交不再交错 +- 移除了按主机的“Add connection”按钮——它把新连接限定在单个服务器上,阻碍了多主机设置 + +## 0.1.46 - 2026-04-04 + +### 修复 + +- 打包构建中的语音激活——Silero VAD 模型现在会从 Electron asar 归档中复制出来,以便原生代码读取 +- 探测客户端的 hello 中会发送应用版本,因此 daemon 的版本门控不再在重新连接的会话中隐藏 Pi/Copilot +- `worktreeRoot` 架构改为向后兼容,适配不发送该字段的旧客户端和 daemon +- 在 CLI 和桌面 daemon 入口中屏蔽了 Punycode 弃用警告(DEP0040) + +## 0.1.45 - 2026-04-04 + +### 新增 + +- Pi (pi.dev) Agent Provider——把 Pi 作为新的 Agent 类型连接,支持思考级别和工具调用 +- 在修复 ACP 兼容性后重新启用了 Copilot Agent Provider +- `paseo .` 和 `paseo ` 会用指定的项目打开桌面应用,类似于 `code .` +- Provider 声明的功能系统——Provider 可以暴露动态的开关和选项,应用会自动渲染它们。首个使用者:Codex fast 模式 +- Codex 计划模式——以仅计划模式启动 Agent,并通过专用的计划卡片界面在执行前审查拟议的更改 +- OpenCode 自定义 Agent 和斜杠命令——opencode.json 中用户定义的 Agent 现在会出现在模式选择器中,斜杠命令接受可选参数 +- 桌面端 Integrations 设置——直接在应用中安装 Paseo CLI 和编排技能,无需动用终端 +- 桌面设置中的 daemon 状态对话框,用于快速检查运行状况 +- 版本不匹配时自动重启 daemon——桌面应用会检测到运行中的 daemon 已过时并自动重启它 +- 移动端欢迎屏幕上的设置提示和 paseo.sh 链接,让新的 App Store 用户知道下一步该做什么 + +### 改进 + +- 桌面端启动更快——已有的 daemon 连接会与引导过程竞速,让应用更早可用 +- 重新排列了设置分区,分组更合理(集成和 daemon 放在一起) +- 侧边栏的项目和工作区现在会跨会话保留,并提供移除项目的上下文菜单 + +### 修复 + +- 切换 iOS 主题时侧边栏崩溃(Unistyles/Reanimated 交互问题) +- CircularBuffer 中的外部缓冲区模式导致的 Silero VAD 崩溃 +- 批量关闭现在会正确归档已存储的 Agent,而不会留下孤儿 +- 关闭标签页时,已固定的已归档 Agent 不再被清理 +- 执行斜杠命令期间 OpenCode 事件流饥饿 +- 多个 git worktree 共享同一根目录时出现重复的工作区 +- 对于登录 shell 设置了不同 PATH 的桌面用户,`gh` 可执行文件的解析 +- Agent 创建超时增加到 60 秒,以应对首次启动较慢的情况 +- 前向兼容的 Provider 处理,旧版应用客户端遇到新的 Provider 类型不会出错 +- Web 滚动条 hook 中输入事件监听器的竞态 +- 打开项目屏幕的内容现在垂直居中 +- 网站下载页面在运行时获取发布版本并校验资源,修复了过时的链接 + +## 0.1.44 - 2026-04-03 + +### 修复 + +- 桌面应用现在会在自动更新重启之前干净地停止 daemon +- 从 Agent 注册表中停用了 claude-acp 和 copilot Provider +- 键盘焦点作用域解析现在会检查多个候选,以获得更广泛的兼容性 +- OpenCode 中断现在与工具调用流程一样达到正确的终止状态 +- 针对 shell 注入、符号链接逃逸和配对端点的安全加固 + +## 0.1.43 - 2026-04-02 + +### 新增 + +- 通过 ACP 基础 Provider 支持 Copilot Agent——把 GitHub Copilot 作为新的 Agent 类型连接 +- 可搜索的模型收藏——快速找到并固定常用的模型 +- OpenCode Agent 支持斜杠命令 + +### 改进 + +- 打磨了模型选择器的体验,移动端底部面板的表现更好 +- 工作区状态现在用琥珀色提醒样式表示“需要输入”状态 +- 消息输入框使用带主题的滚动条,样式保持一致 + +### 修复 + +- 在 Windows 和 Linux 上,终端中的 Ctrl+C/V 复制粘贴现在可以正常工作 +- 在 Windows 上,带空格的 shell 参数现在会被正确加引号 +- 现在可以正确报告支持 1M 上下文的 Claude 模型 + +## 0.1.42 - 2026-04-01 + +### 修复 + +- 修复了 Claude Code 安装在带空格的路径(例如 `C:\Program Files\...`)时在 Windows 上无法启动的问题 + +## 0.1.41 - 2026-04-01 + +### 修复 + +- 修复了 Windows 上创建 Agent 的问题——所有 Provider(Claude、Codex、OpenCode)现在都使用 shell 模式,因此 npm 包装脚本和 `.cmd` 包装可以正确解析 +- 修复了 Windows 上创建终端时默认使用 Unix shell 而不是 `cmd.exe` 的问题 +- 修复了整个应用的路径处理,以支持 Windows 驱动器盘符路径(`C:\...`)和 UNC 路径(`\\...`) +- 修复了 Windows 上的可执行文件解析,使其能配合 `nvm4w` 等 Node 版本管理器使用 +- 通过把原生窗口背景色设置为与主题一致,消除了深色模式下调整窗口大小时的白色闪烁 +- 修复了标题栏拖动区域——用 VS Code 久经验证的静态 CSS `app-region: drag` 模式取代了脆弱的指针事件做法 +- 修复了整个桌面应用中复制/粘贴的上下文菜单 +- 修复了快捷键重新绑定界面,使其显示按住的修饰键并识别更多按键(Tab、Delete、Home、End、Page Up/Down、Insert、F1–F12) +- 移除了活动时间线输出的 40 项上限,长时间的 Agent 会话可以显示完整的历史 + +### 改进 + +- 改进了浅色模式的主题,提供专用的工作区背景、滚动条滑块颜色和更浅的阴影 +- Windows/Linux 上的窗口控件覆盖层高度从 48px 降到 29px,标题栏更紧凑 + +## 0.1.40 - 2026-04-01 + +### 新增 + +- 现在可以批量关闭工作区标签页 + +### 改进 + +- Provider 模型列表现在按服务器和 Provider 缓存,减少了界面中多余的模型查找 + +### 修复 + +- OpenCode 的推理内容不再以重复的助手文本出现 +- Codex 二进制缺失或启动失败时 daemon 不再崩溃 +- 归档后,归档标签页现在会正确地协调 Agent 的可见性 +- 在 Linux 上,工作区中的文件 diff 跟踪现在可以正常工作 +- iPad 布局在桌面模式下可以正确渲染 +- macOS 自动更新现在能正确提供 arm64 和 x64 两种二进制——之前最后构建完成的架构会覆盖另一个架构的更新清单 + +## 0.1.39 - 2026-03-30 + +### 新增 + +- **从 CLI 管理终端**——新的 `paseo terminal` 命令组让你无需离开终端即可列出、创建工作区终端并与之交互 +- **资源管理器中的 Material 文件图标**——文件浏览器树现在显示特定语言的图标(TypeScript、JSON、Markdown 等),让你一眼认出文件 + +### 修复 + +- 修复了多余的溢出裁剪导致的 iOS 侧边栏滚动闪烁 +- 把窗口控件的内边距集中到共享的 hook 中,消除了不同平台之间的布局不一致 + +## 0.1.38 - 2026-03-30 + +### 修复 + +- 修复了 daemon 启动竞态:由于 PID 文件在服务器就绪之前就公布了监听地址,应用在首次启动时可能连接超时 +- 修复了 daemon 日志轮转丢失启动跟踪的问题——trace 级别的 WebSocket 日志不再包含完整的消息负载 + +## 0.1.37 - 2026-03-29 + +### 新增 + +- Windows 和 Linux 上的自定义窗口控件——原生标题栏被替换为与应用设计一致的覆盖层控件 +- 使用 electron-log 的桌面端文件日志,便于调试 daemon 和应用问题 + +### 修复 + +- 修复了 Windows 上 PATH 传递失效和 Claude 二进制解析的问题 +- 语音输入出错时现在会显示可见的提示,而不是静默失败 + +## 0.1.36 - 2026-03-27 + +### 修复 + +- 修复了整个代码库中 Windows 驱动器盘符路径的处理 +- 修复了过时的 Nix 哈希,并自动检测 lockfile 的变更 + +### 新增 + +- 新增了指标收集和终端性能测试 + +## 0.1.35 - 2026-03-26 + +### 改进 + +- 应用启动更快:立即跳转到欢迎屏幕,并行内显示主机连接状态 +- Codex 删除文件现在在 diff 中正确显示为被删除的行 +- OpenCode 的问题现在会在权限界面中显示 + +### 修复 + +- 修复了转为空闲后排队提示词的派发 +- 在 chat 脚本中用可移植的 `while-read` 循环取代了仅限 bash 的 `mapfile` + +### 新增 + +- 新增了对 Nix 和 NixOS 安装的支持 + +## 0.1.34 - 2026-03-25 + +### 新增 + +- 新增 `paseo archive`,作为 `paseo agent archive` 的顶层别名 +- 为 Claude 和 Codex Agent 新增 `PASEO_AGENT_ID` 环境变量 +- 新增重新设计的命令自动补全,带有详情卡片和下拉样式 +- 把 Android 下载入口链接到 Google Play 商店 + +### 改进 + +- 自主回合在被中断时现在会妥善完成,而不是被取消 +- 思考/模型选择现在总是解析为一个真实的选项,而不是显示通用的 Default 选项 +- 恢复了按 Provider 的表单偏好,并移除了 Auto 模型回退 +- 改进了 Codex 活动日志,工具调用摘要更清晰 +- 减少了 Agent 面板和输入区域中不必要的重新渲染,交互更流畅 +- 提高了聊天记录的可读性 + +### 修复 + +- 修复了 `paseo send --no-wait` 不生效的问题 +- 修复了中断后过时的中止结果污染替换回合的问题 +- 修复了 Claude 中断处理和自主唤醒的可靠性 +- 修复了嵌套 Claude Code 会话的检测和 Provider 可用性检查 +- 修复了跨面板的 Agent 输入焦点作用域 +- 修复了订阅时终端快照的顺序 +- 修复了 `chat read --since`,使其接受消息 ID +- 修复了键盘窗格焦点与当前面板的同步 +- 修复了 Web 上的助手文本选择 +- 修复了已归档 Agent 的通知仍出现在聊天室中的问题 +- 修复了消息 Composer 中附加图片按钮的交互 +- 从 Electron 桌面构建中剔除了其他平台的原生二进制 + +## 0.1.33 - 2026-03-23 + +### 修复 + +- 修复了 macOS 上桌面应用关闭后无法重新打开的问题——daemon 和 Agent 进程以主应用实例的身份注册到 Launch Services,阻止了后续启动 +- 修复了打包的桌面应用中语音输入无法使用的问题——hardened runtime 配置中缺少麦克风权限 +- 修复了关闭 Agent 时 Claude Code 子进程泄漏的问题——SDK 查询流没有被正确关闭 +- 通知测试按钮现在会显示错误,而不是静默失败 + +## 0.1.32 - 2026-03-23 + +### 新增 + +- 支持组合键的完全可重新绑定的键盘快捷键——所有快捷键现在都是声明式的,并正确区分 Cmd(Mac)和 Ctrl(Windows/Linux) +- 把桌面应用从 Tauri 迁移到 Electron,支持 macOS 公证、代码签名和 Linux Wayland +- 为文件预览新增行号和自动换行开关 +- 新增已归档 Agent 提示,带有取消归档按钮,可以直接在聊天视图中恢复 Agent +- 在侧边栏中新增工作区类型标识(例如 worktree 与独立工作区) +- 把 diff 语法高亮扩展到更多语言 +- 为项目和 Agent 状态新增状态栏提示 + +### 改进 + +- 把移动端标签页切换器重新设计为紧凑的标题行,可快速创建新 Agent 和终端 +- 简化了工作区创建——worktree 现在通过一个操作行内创建,而不是多步流程 +- 重新连接时 Agent 历史从磁盘流式加载,因此你会立即看到过去的消息,而不是空白屏幕 +- 自动清理过时的工作区:已删除的 worktree 目录和完全归档的工作区会被自动清理 +- 归档工作区后,应用现在会跳转到下一个可用的工作区,而不是让你停留在失效的屏幕上 +- 重新打开已归档的 Agent 标签页现在会保持打开,而不是折叠回已归档状态 +- 减少了工作区屏幕、侧边栏和 Agent 列表中不必要的重新渲染,滚动和交互更流畅 +- 屏幕失去焦点时 Agent 列表不再在后台刷新,节省资源 +- 桌面端按键重复在 macOS 上现在可以正常工作 +- macOS 上的桌面通知更可靠 +- daemon 启动不再被模型下载阻塞 +- daemon 的错误消息更好——RPC 错误现在包含实际的底层细节 + +### 修复 + +- 修复了消息包含结构化内容块时,用户消息在时间线中显示为助手输出的问题 +- 修复了已归档工作区的路由,导航到已归档的会话不再导致应用出错 +- 修复了 Linux AppImage 在仅支持 Wayland 的桌面上无法启动的问题 +- 修复了在不应应用时仍应用桌面窗口拖动坐标的问题 + +## 0.1.30 - 2026-03-19 + +### 新增 + +- 新增终端标签页、拆分窗格控件以及工作区布局的拖放预览 +- 在主要界面中新增合并的模型选择器和 Agent 模式视觉效果 +- 改进了 Open Graph 元数据,网站分享预览更丰富 + +### 改进 + +- 改进了工作区导航,更好地跟踪当前工作区,并支持键盘驱动的窗格交互 +- 改进了终端滚动条行为、窗格焦点处理以及状态栏与消息输入框的间距 +- 改进了项目选择器的路径显示,并整体打磨了工作区界面 + +### 修复 + +- 通过收紧 PATH 解析并在状态中显示缺失的 Provider 二进制,修复了 Agent 启动的可靠性 +- 修复了工作区路由同步、拖动命中区域以及 git diff 面板标题样式的回归 +- 修复了网站在移动端的水平滚动,并确保工作区音频模块在 EAS 安装期间能够构建 + +## 0.1.28 - 2026-03-15 + +### 新增 + +- 新增 OpenCode 的 build 和 plan 模式 +- 新增 Claude Code、Codex 和 OpenCode 的网站落地页 + +### 改进 + +- 改进了 git 操作菜单,仓库操作更可靠 +- 改进了移动端设置屏幕、工作区标题栏操作和欢迎屏幕的呈现 +- 更新了网站首屏文案,并新增了赞助提示区域 + +### 修复 + +- 修复了助手文件链接,使其能从聊天中打开正确的工作区文件 + +## 0.1.27 - 2026-03-13 + +### 新增 + +- 新增语音运行时,采用新的音频引擎架构来支持语音交互 +- 在 Claude 工具调用映射中新增 Grep 工具支持 +- 新增直接从 Agent 聊天消息中打开工作区文件的功能 +- 通过自定义原生桥接新增桌面通知 + +### 改进 + +- 改进了图片选择器、markdown 渲染和界面交互 +- 使用 shell-env 改进了 shell 环境检测 + +### 修复 + +- 修复了特定平台的 markdown 链接渲染 +- 修复了 Linux AppImage 的 CLI 资源路径 +- 修复了 Codex 替换流被过时的回合通知终止的问题 + +## 0.1.26 - 2026-03-12 + +### 新增 + +- 新增桌面端单实例行为、Android APK 下载入口,并刷新了启动屏幕样式 +- 在服务端内置 Codex 和 OpenCode 二进制,安装不再依赖全局安装 +- 新增 Windows 支持,改进了跨平台的 shell 执行 + +### 改进 + +- 改进了 Windows 上的桌面运行时行为:隐藏控制台窗口,并把应用数据默认放在 `~/.paseo` +- 在网站导航中新增了 Discord 链接 + +### 修复 + +- 修复了从托管运行时启动桌面端 Claude Agent 的问题,并在重启时正确轮转日志 +- 修复了主页路由,使其在适当时隐藏浏览器界面 +- 通过更新 `exclusionList` 的导入修复了 Expo Metro 兼容性 +- 修复了嘈杂的 shell 输出干扰可执行文件查找的问题 +- 通过去除扩展长度路径前缀修复了 Windows 资源路径的处理 + +## 0.1.25 - 2026-03-11 + +### 修复 + +- 修复了在全新安装的 macOS 上桌面应用无法启动内置 daemon 的问题。DMG 未经公证,并且代码签名去除了内置 Node 运行时的权限,导致 Gatekeeper 阻止其执行 +- 通过恢复 AppImage 打包格式并去除 onnxruntime 的 CUDA 依赖,修复了 Linux AppImage 构建 + +## 0.1.24 - 2026-03-10 + +### 改进 + +- 改进了命令中心的键盘导航和新建标签页快捷键 +- 简化了桌面端发布流水线,构建更快、更可靠 + +## 0.1.21 - 2026-03-10 + +### 改进 + +- 通过修复 GitHub Actions 发布期间 Windows 托管运行时的构建路径,提高了桌面端发布的可靠性 + +### 修复 + +- 修复了 Windows 运行器上仅限 Unix 的服务端构建脚本导致的桌面端发布 CI 失败 +- 修复了服务端 CI,使其在运行测试之前构建中继依赖,在干净的运行器上恢复了中继 E2EE 测试覆盖 +- 修复了一个依赖本地安装 Claude CLI 的 Claude 重新设计测试 + +## 0.1.20 - 2026-03-10 + +### 新增 + +- 新增工作区侧边栏的 git 操作,带有快速 diff 统计和归档控件 +- 为桌面端安装刷新了网站下载页和主页呈现 + +### 改进 + +- 桌面端发布打包现在会在 CI 期间重新构建并校验内置的托管运行时,提高了 macOS 用户安装程序的可靠性 +- 改进了桌面端和 Web 的流式渲染、设置细节,以及与 React 19.1.4 的兼容性 + +### 修复 + +- 修复了 Claude 中断/重启的回归,并加强了桌面端发布中托管 daemon 的冒烟测试覆盖 + +## 0.1.19 - 2026-03-09 + +### 新增 + +- 新增 GitHub 草稿发布流程,维护者可以在发布最终版本之前上传并审查桌面端和 Android 发布资源 + +## 0.1.18 - 2026-03-06 + +### 新增 + +- 新增桌面端 `Mod+W` 快捷键,用于关闭当前标签页 + +### 改进 + +- 新建和新选中的终端现在会自动获得焦点,你可以立即输入 +- 新创建的工作区和项目在侧边栏中保持更稳定的顺序 +- 改进了 GitHub 远程仓库的项目命名,并把项目图标的查找扩展到 Phoenix 的 `priv/static` 资源 +- 更新了网站的桌面端下载链接,改用 macOS 通用 DMG + +### 修复 + +- 恢复了 Claude 运行时自动生成 Agent 元数据 + +## 0.1.17 - 2026-03-06 + +### 新增 + +- 新的以工作区为先的导航模型,提供工作区标签页、文件标签页和可排序的标签页组 +- 工作区和标签页导航的键盘快捷键,侧边栏中显示快捷键徽标 +- 工作区级别的归档操作,改进了 worktree 归档流程并支持上下文菜单 +- 聊天中的任务通知以合成的工具调用事件渲染,状态跟踪更清晰 + +### 改进 + +- 桌面构建现在以 macOS 通用二进制(Apple Silicon + Intel)发布 +- 在刷新和深层链接之间,工作区路由和标签页标识的处理更可靠 +- 侧边栏的拖放行为更好,提供明确的拖动手柄和嵌套列表交互 +- 终端/文件渲染更流畅,并通过 WebGL 提升了终端性能 +- 更好地显示 Provider 错误,并更新了 Claude 模型/运行时的处理 + +### 修复 + +- 修复了非规范的标签页路由导致的孤儿工作区运行 +- 修复了移动端终端标签页重新挂载/路由恢复的问题 +- 修复了 Agent 元数据标题/分支更新的可靠性 +- 修复了应用中流/时间线顺序和光标同步的问题 +- 修复了聊天/工具流视图中边缘滚轮滚动方向相反的问题 + +## 0.1.16 - 2026-02-22 + +### 新增 + +- 直接在 Settings 中更新 Paseo 桌面应用和本地 daemon +- Settings 中的麦克风和通知权限控件 +- 思考/推理模式——当 Provider 支持时,Agent 可以使用扩展思考 +- 自主运行模式——让 Agent 持续工作,无需在每一步手动审批 +- `paseo wait` 现在会在等待期间显示 Agent 最近活动的快照 + +### 改进 + +- 在长时间的 Agent 运行期间流式输出更流畅,界面闪烁和滚动跳动更少 +- Agent 侧边栏列表渲染更快 +- 归档 Agent 现在会先停止它,而不是归档一个运行到一半的会话 +- 刷新时 Agent 标题不再被重置 +- 中继连接更可靠 + +### 修复 + +- 修复了 Claude 后台任务导致聊天不同步的问题 +- 修复了时间线中出现重复用户消息的问题 +- 修复了 OpenCode SDK 更新导致的启动崩溃 +- 修复了后台 Agent 活动引发的虚假“需要关注”通知 + +## 0.1.15 - 2026-02-19 + +### 新增 + +- 在网站上新增公开的更新日志页面,用户可以浏览发布说明 + +### 改进 + +- 把网站的入门体验重新设计为更清晰的两步流程 +- 简化了网站的 GitHub 导航和更新日志标题 +- 改进了应用的草稿/新 Agent 体验,工作目录占位符和空状态提示更清晰 +- 在桌面端草稿屏幕上之前未处理的区域启用了拖动交互 +- 在左侧边栏中隐藏了空的筛选分组 + +### 修复 + +- 通过把已归档 Agent 的路由重定向到草稿,修复了已归档 Agent 的导航 +- 修复了重复的 `/rewind` 用户消息行为 + +## 0.1.14 - 2026-02-19 + +### 新增 + +- 新增对 Claude `/rewind` 命令的支持 +- 在草稿 Agent 的 Composer 中新增斜杠命令入口 +- 在聊天提示词中新增 `@` 工作区文件自动补全 +- 新增把图片直接粘贴到提示词附件中的支持 +- 为待发送用户消息的附件新增乐观的图片预览 +- 新增桌面端/Web 共享的覆盖式滚动手柄,包括文件预览窗格 + +### 改进 + +- 改进了交付后的 worktree 流程,包括更好的已合并 PR 检测 +- 选择 CWD 后立即启用资源管理器侧边栏,改进了草稿工作流 +- 把 CWD 预填为主仓库,改进了新 worktree Agent 的默认值 +- 改进了桌面端命令自动补全的行为,使其与组合框交互一致 +- 改进了 git 同步体验:简化了同步标签,并且只在分支与 origin 分叉时显示 Sync +- 改进了桌面端的设置和权限体验 +- 改进了 Web/桌面端滚动条的可见性、拖动交互、跟踪和动画时序 + +### 修复 + +- 修复了 worktree 归档/设置的生命周期问题,包括终端清理和归档时机 +- 通过对 CWD 取哈希来生成不冲突的 worktree 根目录,修复了 worktree 路径冲突 +- 修复了切换回 Agent 会话时的终端尺寸 +- 为运行中的 shell 命令添加确认,修复了误关终端的风险 +- 修复了侧边栏和 Agent 屏幕之间归档加载状态的一致性 +- 修复了自动补全弹出框的稳定性和工作区建议的排序 +- 修复了悬而未决的非最终片段导致的语音输入超时 +- 通过使用父进程 PID 归属,修复了作为子进程启动时服务器锁的归属问题 +- 修复了服务器 CWD 建议中泄漏隐藏目录的问题 +- 修复了不同 Provider 之间 Agent 提醒通知负载的一致性 +- 修复了 daemon 版本数据不可用时设置中 daemon 版本徽标的可见性 + +## 0.1.9 - 2026-02-17 + +### 改进 + +- 通过单一共享的架构校验和重试流水线统一了结构化输出的生成 +- 在结构化生成的回退选择中复用了 Provider 可用性检查 +- 为内部元数据和 git 文本生成新增了结构化生成的瀑布顺序:先 Claude Haiku,然后 Codex,最后 OpenCode + +### 修复 + +- 修复了 CLI `run --output-schema`,使其使用共享的结构化输出路径,而不是临时的 JSON 解析 +- 修复了 Provider 返回空 `lastMessage` 时 `run --output-schema` 失败的问题,改为从时间线的助手输出中恢复 +- 修复了内部提交信息、拉取请求文本和 Agent 元数据的生成,使其遵循同一个一致的结构化流水线 + +## 0.1.8 - 2026-02-17 + +### 新增 + +- 为 daemon 重启新增跨平台的确认对话框流程 + +### 改进 + +- 简化了本地语音的引导和 daemon 启动锁的行为 +- 更新了网站首屏文案,强调本地执行 + +### 修复 + +- 修复了应用和服务端会话处理中卡住的“运行时发送”恢复 +- 修复了重新加载已有 Agent 时 Claude 会话身份的保留 +- 修复了组合框选项的行为及相关交互 +- 修复了桌面端文件拖放监听器的清理,避免未捕获的取消监听错误 +- 修复了 Web 工具详情在滚动边缘的滚轮事件路由 + +## 0.1.7 - 2026-02-16 + +### 新增 + +- 改进了 Agent 工作区流程,目录建议更好 +- 在网站上新增 iOS TestFlight 和 Android 应用的访问申请表单 + +### 改进 + +- 统一了开发路径和 CLI 路径的 daemon 启动行为,本地运行更可预测 +- 改进了网站的应用下载和更新指引 + +### 修复 + +- 避免了桌面端组合框初次出现时在 `0,0` 位置闪烁 +- 修复了 CLI 版本输出的问题 +- 加固了服务端运行时对本地语音依赖的加载 + +## 0.1.6 - 2026-02-16 + +### 说明 + +- 此补丁版本没有明显的产品变化 + +## 0.1.5 - 2026-02-16 + +### 新增 + +- 新增终端重新附加支持,改进了 worktree 终端的处理 +- 在应用中新增全局键盘快捷键帮助 +- 新增侧边栏按主机筛选,改进了 Agent 工作流控件 + +### 改进 + +- 通过流式显示设置进度,提高了 worktree 设置的可见性 +- 改进了终端流式输出的可靠性和生命周期处理 +- 保留资源管理器标签页状态,导航时上下文保留得更好 + +## 0.1.4 - 2026-02-14 + +### 新增 + +- 在客户端新增语音能力状态报告 +- 新增后台下载本地语音模型,并按运行时进行门控 +- 根据服务器提供的预算新增自适应的语音输入结束时机 +- 新增带宽限期的中继重新连接行为和分支建议 + +### 改进 + +- 提高了连接选择和 Agent 数据加载的可靠性 +- 通过基于游标的获取改进了时间线加载 +- 通过引导默认的 localhost 连接改进了首次运行体验 +- 通过自动把 URL 转为链接改进了行内代码的渲染 + +### 修复 + +- 修复了 Linux 检出 diff 的监视行为,避免递归监视 +- 修复了过时的中继客户端计时器行为 +- 修复了折叠时不必要的 git diff 标题自动滚动 + +## 0.1.3 - 2026-02-12 + +### 新增 + +- 新增 CLI 引导命令 +- 新增 CLI `--output-schema` 支持,用于结构化的 Agent 输出 +- 新增 CLI 对 Agent 元数据中名称和标签的更新支持 +- 新增 Provider 可用性检测,并规范化旧的默认模型 ID + +### 改进 + +- 改进了文件浏览器的刷新反馈和未解析检出的回退处理 +- 通过语音开始的宽限期改进了语音中断的处理 +- 改进了 CLI 默认值,默认列出所有未归档的 Agent +- 改进了网站体验,安装引导更清晰,并可访问隐私政策 + +### 修复 + +- 修复了开发运行器入口的问题和 sherpa TTS 的初始化行为 + +## 0.1.2 - 2026-02-11 + +### 说明 + +- 此补丁版本没有明显的产品变化 + +## 0.1.1 - 2026-02-11 + +### 新增 + +- 首个 `0.1.x` 发布系列 diff --git a/CLAUDE.md b/CLAUDE.md index fc431da2c5..71682af7e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,18 +36,23 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | | [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu | | [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | +| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | +| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | | [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | +| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | +| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | @@ -62,6 +67,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata | | [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security | | [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | +| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop | | [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | | [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority | @@ -106,14 +112,15 @@ npm run format:check # Check formatting without writing Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`. +When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures. + See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging. ## Release branches -When the user says "this goes to next", create or -retarget the PR to `next` and preserve that destination through delivery. Follow -[release branch discipline](docs/release.md#release-branch-discipline) for creating -and updating `next`, integrating it after a release, and releasing a hotfix from a tag. +Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each +release builds in a short-lived release workspace from `origin/main` or a release tag; see +[release base and source](docs/release.md#release-base-and-source). ## Critical rules diff --git a/README.zh-CN.md b/README.zh-CN.md index 745071f0fe..3a9680b512 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,8 +59,8 @@ - - + +
Hoplite
Cloud coding agents
成为 Paseo 赞助商成为 Paseo 赞助商成为 Paseo 赞助商成为 Paseo 赞助商
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a ### 桌面 app(推荐) -从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。 +从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。 如果要从手机连接,在 Settings 中扫描显示的二维码。 @@ -97,8 +97,8 @@ paseo 完整安装和配置见: -- [文档](https://paseo.sh/docs) -- [配置参考](https://paseo.sh/docs/configuration) +- [文档](https://paseo.sh/zh-CN/docs) +- [配置参考](https://paseo.sh/zh-CN/docs/configuration) ## CLI @@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务 paseo --host workstation.local:6767 run "run the full test suite" ``` -更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。 +更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。 ## Skills @@ -164,7 +164,7 @@ npm run typecheck ## 支持 Paseo -Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。 +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。 ## 相关项目 diff --git a/SECURITY.md b/SECURITY.md index ea23984fa0..e17118459e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. -3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. +3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes. +4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping. The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. @@ -34,15 +34,15 @@ The daemon requires a valid cryptographic handshake before processing any comman - **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone - **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon. -- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake -- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected -- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. +- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode +- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode +- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated. ### Trust model The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. -When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available. +When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon. ## Local daemon trust boundary diff --git a/assets/store-screenshots/.gitignore b/assets/store-screenshots/.gitignore new file mode 100644 index 0000000000..22908c2c7d --- /dev/null +++ b/assets/store-screenshots/.gitignore @@ -0,0 +1 @@ +output/preview.png diff --git a/assets/store-screenshots/README.md b/assets/store-screenshots/README.md new file mode 100644 index 0000000000..fc37d98bd8 --- /dev/null +++ b/assets/store-screenshots/README.md @@ -0,0 +1,102 @@ +# Store screenshots + +Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change. + +| # | Headline | Capture | Phone callout | Tablet callout | +| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- | +| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) | +| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) | +| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover | +| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) | +| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree | + +On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`. + +## Creative direction + +The panels follow paseo.sh: + +- **Canvas:** the site's `#101615` background. +- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines. +- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height. +- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site. +- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred. +- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain. + +Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups. + +Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it. + +## Files + +- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts. +- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout. +- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there. +- `demo/`: the staged demo host the captures come from. +- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot. +- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures. +- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot. +- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures. +- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store. + +All exports are opaque RGB PNGs. + +## Render + +```sh +SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs +``` + +`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font. + +Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased. + +Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing. + +Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it. + +## Recapture + +1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree: + + ```sh + npx tsx assets/store-screenshots/demo/stage.mts + ``` + + Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output. + +2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs. + + ```sh + EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port + ssh -o ExitOnForwardFailure=yes -R :127.0.0.1: -R 7791:127.0.0.1:7791 + ``` + +3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set. + - Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar override`). + - On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays. + - Name the host "MacBook Pro" and set the sidebar badge to Hidden. + - Group the sidebar by status. + - If you reset the simulator, reinstall the dev client. + +4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture. + +5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store: + + ```sh + agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868 + agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752 + ``` + + - **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat. + - **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks. + - **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 "" Enter` instead of the simulator keyboard, then dismiss the software keyboard. + - **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`. + +6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview. + +## Publish + +The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports. + +Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). diff --git a/assets/store-screenshots/demo/change/apply.mjs b/assets/store-screenshots/demo/change/apply.mjs new file mode 100644 index 0000000000..ae5efe295b --- /dev/null +++ b/assets/store-screenshots/demo/change/apply.mjs @@ -0,0 +1,84 @@ +// Applies the demo change to a checkout of the Paseo repository. +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = process.argv[2]; +const client = path.join(repo, "packages/client/src"); + +copyFileSync( + path.join(here, "reconnect-delay.ts"), + path.join(client, "connection/reconnect-delay.ts"), +); +copyFileSync( + path.join(here, "reconnect-delay.test.ts"), + path.join(client, "connection/reconnect-delay.test.ts"), +); + +function edit(file, pairs) { + let text = readFileSync(file, "utf8"); + for (const [from, to] of pairs) { + if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`); + text = text.replace(from, to); + } + writeFileSync(file, text); +} + +edit(path.join(client, "daemon-client.ts"), [ + [ + `import { CreationClient } from "./creation/index.js";\n`, + `import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`, + ], + [ + ` private reconnectAttempt = 0;\n`, + ` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`, + ], + [ + ` nextRetryAt: new Date( + Date.now() + + Math.min( + (this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) * + 2 ** this.reconnectAttempt, + this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + ), + ).toISOString(), +`, + ` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(), +`, + ], + [ + ` private armReconnectTimer(): void { + const attempt = this.reconnectAttempt; + const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; + const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; + const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); +`, + ` // The countdown and the timer must agree, so each attempt draws its jitter once. + private getNextReconnectDelayMs(): number { + const attempt = this.reconnectAttempt; + if (this.nextReconnectDelay?.attempt !== attempt) { + this.nextReconnectDelay = { + attempt, + delayMs: getReconnectDelayMs(attempt, { + baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS, + maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + }), + }; + } + return this.nextReconnectDelay.delayMs; + } + + private armReconnectTimer(): void { + const attempt = this.reconnectAttempt; + const delay = this.getNextReconnectDelayMs(); +`, + ], +]); + +edit(path.join(repo, "CHANGELOG.md"), [ + [ + `# Changelog\n\n`, + `# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`, + ], +]); diff --git a/assets/store-screenshots/demo/change/reconnect-delay.test.ts b/assets/store-screenshots/demo/change/reconnect-delay.test.ts new file mode 100644 index 0000000000..3b5367784c --- /dev/null +++ b/assets/store-screenshots/demo/change/reconnect-delay.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { getReconnectDelayMs } from "./reconnect-delay"; + +const options = { baseDelayMs: 1500, maxDelayMs: 30_000 }; + +describe("getReconnectDelayMs", () => { + it("doubles the ceiling on each attempt", () => { + const top = () => 1; + expect(getReconnectDelayMs(0, options, top)).toBe(1500); + expect(getReconnectDelayMs(1, options, top)).toBe(3000); + expect(getReconnectDelayMs(2, options, top)).toBe(6000); + }); + + it("never waits longer than the max delay", () => { + expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000); + }); + + it("spreads retries between half and the full ceiling", () => { + expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000); + expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000); + expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000); + }); + + it("keeps the first retry under two seconds", () => { + for (let i = 0; i < 100; i += 1) { + expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500); + } + }); +}); diff --git a/assets/store-screenshots/demo/change/reconnect-delay.ts b/assets/store-screenshots/demo/change/reconnect-delay.ts new file mode 100644 index 0000000000..88de867aa6 --- /dev/null +++ b/assets/store-screenshots/demo/change/reconnect-delay.ts @@ -0,0 +1,18 @@ +export interface ReconnectDelayOptions { + baseDelayMs: number; + maxDelayMs: number; +} + +/** + * Exponential backoff with equal jitter. Half of each delay is fixed and half + * is random, so a relay restart spreads reconnects out instead of every client + * retrying on the same tick. + */ +export function getReconnectDelayMs( + attempt: number, + options: ReconnectDelayOptions, + random: () => number = Math.random, +): number { + const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs); + return Math.round(ceiling / 2 + random() * (ceiling / 2)); +} diff --git a/assets/store-screenshots/demo/preview-deploys/index.client.tsx b/assets/store-screenshots/demo/preview-deploys/index.client.tsx new file mode 100644 index 0000000000..72ab9a709a --- /dev/null +++ b/assets/store-screenshots/demo/preview-deploys/index.client.tsx @@ -0,0 +1,204 @@ +// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on +// every composer that opens the latest preview deploy for each branch. The +// deploys are fixed sample data; the UI is a real plugin surface. +import type { + PluginButtonContentProps, + PluginButtonRegistration, + PluginClientContext, +} from "@getpaseo/plugin/client"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; + +type DeployStatus = "ready" | "building" | "failed"; + +interface Deploy { + branch: string; + status: DeployStatus; + detail: string; + age: string; + progress?: number; +} + +const DEPLOYS: Deploy[] = [ + { branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" }, + { + branch: "perf/provider-snapshot-cache", + status: "building", + detail: "Bundling app", + age: "now", + progress: 0.64, + }, + { branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" }, + { + branch: "i18n/ko-settings", + status: "failed", + detail: "Missing key settings.notifications", + age: "26m", + }, +]; + +function DeploysSheet({ theme, layout }: PluginButtonContentProps) { + const mono = layout.platform === "ios" ? "Menlo" : "monospace"; + const styles = useMemo( + () => ({ + // Popovers on tablets and desktop are narrow by default; give the + // branch names room. Phones show the sheet at full width. + body: { gap: 14, width: layout.compact ? undefined : 400 }, + production: { + padding: 14, + gap: 6, + borderRadius: 12, + backgroundColor: theme.colors.surface2, + }, + productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 }, + label: { color: theme.colors.foregroundMuted, fontSize: 13 }, + version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const }, + live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const }, + heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 }, + list: { + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.border, + overflow: "hidden" as const, + }, + row: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 12, + paddingVertical: 12, + paddingHorizontal: 14, + }, + divider: { height: 1, backgroundColor: theme.colors.border }, + text: { flex: 1, gap: 4 }, + branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono }, + detail: { color: theme.colors.foregroundMuted, fontSize: 13 }, + failed: { color: theme.colors.statusDanger, fontSize: 13 }, + age: { color: theme.colors.foregroundMuted, fontSize: 13 }, + track: { + height: 4, + marginTop: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface2, + overflow: "hidden" as const, + }, + }), + [theme, mono, layout.compact], + ); + const dotColor: Record = { + ready: theme.colors.statusSuccess, + building: theme.colors.statusWarning, + failed: theme.colors.statusDanger, + }; + + return ( + + + + + Live + Production · main + + v0.14.0-beta.1 + Deployed 3h ago + + Previews + + {DEPLOYS.map((deploy, index) => ( + + {index > 0 ? : null} + + + + + {deploy.branch} + + + {deploy.detail} + + {deploy.progress !== undefined ? ( + + ) : null} + + {deploy.age} + + + ))} + + + ); +} + +function Progress({ value, track, color }: { value: number; track: object; color: string }) { + const fill = useMemo( + () => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }), + [value, color], + ); + return ( + + + + ); +} + +function Dot({ color }: { color: string }) { + const style = useMemo( + () => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }), + [color], + ); + return ; +} + +export default function contribute(client: PluginClientContext) { + const pills = new Map(); + const lifetime = new AbortController(); + const register = (agent: { id: string; workspaceId?: string | null }) => { + if (lifetime.signal.aborted || !agent.workspaceId) return; + pills.get(agent.id)?.remove(); + pills.set( + agent.id, + client.addComposerPill({ + id: "deploys", + workspaceId: agent.workspaceId, + agentId: agent.id, + button: { + title: "Preview deploys", + icon: "Rocket", + label: "Deploys", + behavior: { kind: "popover", Content: DeploysSheet }, + }, + }), + ); + }; + void client.paseo.agents + .list({ subscribe: {}, signal: lifetime.signal }) + .then(({ subscription }) => { + subscription.subscribe({ + snapshot: ({ entries }) => { + for (const pill of pills.values()) pill.remove(); + pills.clear(); + for (const { agent } of entries) register(agent); + }, + update: (message) => { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "remove") { + pills.get(update.agentId)?.remove(); + pills.delete(update.agentId); + } else register(update.agent); + }, + }); + return undefined; + }) + .catch((error) => { + if (!lifetime.signal.aborted) console.error("Agent observation failed", error); + }); + return () => { + lifetime.abort(); + for (const pill of pills.values()) pill.remove(); + pills.clear(); + }; +} diff --git a/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json b/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json new file mode 100644 index 0000000000..0da849e8f7 --- /dev/null +++ b/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "preview-deploys", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/assets/store-screenshots/demo/scripted-agents.mts b/assets/store-screenshots/demo/scripted-agents.mts new file mode 100644 index 0000000000..603017f323 --- /dev/null +++ b/assets/store-screenshots/demo/scripted-agents.mts @@ -0,0 +1,281 @@ +// Scripted Claude Code and Codex clients for staging store screenshots. +// Each agent replays a fixed conversation through the real daemon, so the app +// renders real timeline rows, permission prompts, and running states. +import { randomUUID } from "node:crypto"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + ToolCallDetail, +} from "../../../packages/server/src/server/agent/agent-sdk-types.js"; +import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; + +export type ScriptStep = + | { kind: "say"; text: string } + | { kind: "think"; text: string } + | { kind: "tool"; name: string; detail: ToolCallDetail } + | { kind: "todo"; items: { text: string; completed: boolean }[] } + | { kind: "wait"; ms: number } + | { kind: "running-tool"; name: string; detail: ToolCallDetail } + | { kind: "ask"; name: string; title: string; detail: ToolCallDetail }; + +export interface AgentScript { + /** Matched against the start of the prompt that opens the turn. */ + prompt: string; + steps: ScriptStep[]; + /** Leave the turn open after the last step. */ + keepRunning?: boolean; + /** Pause between steps, so "Worked for" reflects a believable duration. */ + stepDelayMs?: number; +} + +const CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, +}; + +const MODELS: Record = { + claude: [ + { + provider: "claude", + id: "claude-opus-5-5", + label: "Opus 5.5", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + { provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" }, + ], + codex: [ + { + provider: "codex", + id: "gpt-5.6-sol", + label: "GPT-5.6-Sol", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + ], +}; + +function promptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") return prompt; + return prompt + .map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")) + .join(""); +} + +class ScriptedSession implements AgentSession { + readonly capabilities = CAPABILITIES; + readonly id = randomUUID(); + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private pending: AgentPermissionRequest[] = []; + private activeTurnId: string | null = null; + + constructor( + readonly provider: string, + private readonly config: AgentSessionConfig, + private readonly scripts: AgentScript[], + ) {} + + private emit(event: AgentStreamEvent): void { + const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event; + this.history.push(tagged); + for (const listener of this.listeners) listener(tagged); + } + + private timeline(item: AgentTimelineItem): void { + this.emit({ type: "timeline", provider: this.provider, item }); + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + const turnId = randomUUID(); + this.activeTurnId = turnId; + const text = promptText(prompt); + const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt)); + setTimeout(() => void this.play(text, script), 50); + return { turnId }; + } + + private async play(text: string, script: AgentScript | undefined): Promise { + this.emit({ type: "turn_started", provider: this.provider }); + this.timeline({ type: "user_message", text, messageId: randomUUID() }); + const delay = script?.stepDelayMs ?? 400; + for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) { + await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay)); + this.playStep(step); + if (step.kind === "ask") return; + } + if (script?.keepRunning) return; + this.emit({ + type: "turn_completed", + provider: this.provider, + usage: { inputTokens: 48_210, outputTokens: 3_904 }, + }); + this.activeTurnId = null; + } + + private playStep(step: ScriptStep): void { + switch (step.kind) { + case "say": + this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() }); + return; + case "think": + this.timeline({ type: "reasoning", text: step.text }); + return; + case "todo": + this.timeline({ type: "todo", items: step.items }); + return; + case "wait": + return; + case "tool": + case "running-tool": { + const callId = randomUUID(); + this.timeline({ + type: "tool_call", + callId, + name: step.name, + status: "running", + error: null, + detail: step.detail, + }); + if (step.kind === "tool") { + this.timeline({ + type: "tool_call", + callId, + name: step.name, + status: "completed", + error: null, + detail: step.detail, + }); + } + return; + } + case "ask": { + const request: AgentPermissionRequest = { + id: randomUUID(), + provider: this.provider, + name: step.name, + kind: "tool", + title: step.title, + detail: step.detail, + }; + this.pending = [request]; + this.emit({ type: "permission_requested", provider: this.provider, request }); + } + } + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + async *streamHistory(): AsyncGenerator { + yield* this.history; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null, + thinkingOptionId: this.config.thinkingOptionId ?? "medium", + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes(): Promise { + return getAgentProviderDefinition(this.provider).modes; + } + + async getCurrentMode(): Promise { + return this.config.modeId ?? null; + } + + async setMode(): Promise {} + + getPendingPermissions(): AgentPermissionRequest[] { + return this.pending; + } + + async respondToPermission(requestId: string, resolution: AgentPermissionResponse) { + this.pending = []; + this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution }); + } + + describePersistence(): AgentPersistenceHandle { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise { + if (!this.activeTurnId) return; + this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" }); + this.activeTurnId = null; + } + + async close(): Promise {} +} + +export class ScriptedAgentClient implements AgentClient { + readonly capabilities = CAPABILITIES; + + constructor( + readonly provider: string, + private readonly scripts: AgentScript[], + ) {} + + async createSession(config: AgentSessionConfig): Promise { + return new ScriptedSession(this.provider, config, this.scripts); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + return new ScriptedSession( + this.provider, + { provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides }, + this.scripts, + ); + } + + async fetchCatalog() { + return { + models: MODELS[this.provider] ?? [], + modes: getAgentProviderDefinition(this.provider).modes, + }; + } + + async isAvailable(): Promise { + return true; + } +} diff --git a/assets/store-screenshots/demo/stage.mts b/assets/store-screenshots/demo/stage.mts new file mode 100644 index 0000000000..3c6a3cadf0 --- /dev/null +++ b/assets/store-screenshots/demo/stage.mts @@ -0,0 +1,556 @@ +// Stages the demo host used for store screenshots. +// +// npx tsx assets/store-screenshots/demo/stage.mts +// +// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude +// Code and Codex clients, adds a local clone of this repository as the `paseo` +// project, cuts real worktrees, applies a real change to the hero worktree, and +// opens a terminal there. Leave it running while you capture. +import { execFileSync, execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js"; +import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js"; +import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../.."); +const demoRoot = path.join(repoRoot, ".dev/store-capture/demo"); +const codeRoot = path.join(demoRoot, "code"); +const pluginDir = path.join(here, "preview-deploys"); +const port = Number(process.env.DEMO_PORT ?? 7791); +const minute = 60_000; + +const claudeScripts: AgentScript[] = []; +const codexScripts: AgentScript[] = []; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], { + cwd, + encoding: "utf8", + }); +} + +function run(cwd: string, command: string): string { + return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } }); +} + +async function prepareRepos(): Promise { + // A fresh clone each run: worktrees from an earlier run stay registered in it. + // Worktree setup (npm ci, builds) is dropped so new worktrees open instantly. + const paseo = path.join(codeRoot, "paseo"); + await rm(paseo, { recursive: true, force: true }); + await mkdir(codeRoot, { recursive: true }); + execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]); + git(paseo, "checkout", "-q", "-B", "main"); + git(paseo, "remote", "remove", "origin"); + const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8")); + delete config.worktree; + await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`); + git(paseo, "commit", "-qam", "chore: skip worktree setup in demo"); + const dotfiles = path.join(codeRoot, "dotfiles"); + if (!existsSync(dotfiles)) { + await mkdir(dotfiles, { recursive: true }); + await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n"); + await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n"); + git(dotfiles, "init", "-q", "-b", "main"); + git(dotfiles, "add", "."); + git(dotfiles, "commit", "-qm", "Initial dotfiles"); + } +} + +async function main(): Promise { + await prepareRepos(); + const home = path.join(demoRoot, "home"); + await rm(home, { recursive: true, force: true }); + await mkdir(home, { recursive: true }); + + const daemon = await createTestPaseoDaemon({ + agentClients: { + claude: new ScriptedAgentClient("claude", claudeScripts), + codex: new ScriptedAgentClient("codex", codexScripts), + }, + listen: "127.0.0.1", + listenPort: port, + paseoHomeRoot: home, + mcpEnabled: false, + cleanup: false, + pluginsEnabled: true, + plugins: { + "preview-deploys": { source: "directory", path: pluginDir, enabled: true }, + }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0", + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + + const paseo = await client.addProject(path.join(codeRoot, "paseo")); + const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles")); + const paseoProjectId = paseo.project!.projectId; + + async function worktree(title: string, branchName: string) { + const result = await client.createWorkspace({ + title, + source: { + kind: "worktree", + projectId: paseoProjectId, + action: "branch-off", + baseBranch: "main", + branchName, + }, + }); + if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`); + return result.workspace; + } + + async function agent(input: { + provider: "claude" | "codex"; + workspace: { id: string; workspaceDirectory?: string | null }; + cwd: string; + title: string; + script: AgentScript; + }) { + (input.provider === "claude" ? claudeScripts : codexScripts).push(input.script); + return client.createAgent({ + provider: input.provider, + cwd: input.cwd, + workspaceId: input.workspace.id, + title: input.title, + initialPrompt: input.script.prompt, + }); + } + + const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // Done: finished earlier and already read. + const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1"); + const releaseAgent = await agent({ + provider: "codex", + workspace: release, + cwd: release.workspaceDirectory!, + title: "Release v0.14.0-beta.1", + script: { + prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.", + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: "npm run release:beta", + output: "Tagged v0.14.0-beta.1\n", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.", + }, + ], + }, + }); + const dotfilesWorkspace = ( + await client.createWorkspace({ + title: "Add a tmux status line", + source: { + kind: "directory", + path: path.join(codeRoot, "dotfiles"), + projectId: dotfiles.project!.projectId, + }, + }) + ).workspace!; + const tmuxAgent = await agent({ + provider: "claude", + workspace: dotfilesWorkspace, + cwd: path.join(codeRoot, "dotfiles"), + title: "Add a tmux status line", + script: { + prompt: "Add a minimal tmux status line with the session name and battery.", + steps: [ + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "tmux.conf", + oldString: "", + newString: "set -g status-right '#S · #{battery_percentage}'", + }, + }, + { + kind: "say", + text: "Added a status line with the session name on the left and battery on the right.", + }, + ], + }, + }); + await pause(3000); + await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]); + + // Working: turns that stay open while you capture. + const ipad = await worktree( + "Keep the composer above the iPad keyboard", + "fix/ipad-keyboard-inset", + ); + await agent({ + provider: "codex", + workspace: ipad, + cwd: ipad.workspaceDirectory!, + title: "Keep the composer above the iPad keyboard", + script: { + prompt: + "On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.", + keepRunning: true, + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: 'rg -n "keyboardHeight" packages/app/src', + output: + "packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n", + exitCode: 0, + }, + }, + { + kind: "tool", + name: "read", + detail: { + type: "read", + filePath: "packages/app/src/keyboard/shift/internal/provider.ts", + }, + }, + { + kind: "say", + text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.", + }, + { + kind: "running-tool", + name: "shell", + detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" }, + }, + ], + }, + }); + const snapshots = await worktree( + "Cache provider snapshots on disk", + "perf/provider-snapshot-cache", + ); + await agent({ + provider: "claude", + workspace: snapshots, + cwd: snapshots.workspaceDirectory!, + title: "Cache provider snapshots on disk", + script: { + prompt: + "Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.", + keepRunning: true, + steps: [ + { + kind: "todo", + items: [ + { text: "Find where snapshots are built", completed: true }, + { text: "Persist the last snapshot per host", completed: false }, + { text: "Refresh in the background after boot", completed: false }, + ], + }, + { + kind: "tool", + name: "Read", + detail: { + type: "read", + filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts", + }, + }, + { + kind: "running-tool", + name: "Edit", + detail: { + type: "edit", + filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts", + }, + }, + ], + }, + }); + + // Needs input: waiting on a permission. + const korean = await worktree("Korean translations for Settings", "i18n/ko-settings"); + await agent({ + provider: "claude", + workspace: korean, + cwd: korean.workspaceDirectory!, + title: "Korean translations for Settings", + script: { + prompt: "Translate the new Settings strings into Korean and open a PR.", + steps: [ + { + kind: "tool", + name: "Read", + detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" }, + }, + { + kind: "tool", + name: "Edit", + detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "npm run test --workspace=@getpaseo/app -- i18n", + output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n", + exitCode: 0, + }, + }, + { kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." }, + { + kind: "ask", + name: "Bash", + title: "Bash", + detail: { type: "shell", command: "git push -u origin i18n/ko-settings" }, + }, + ], + }, + }); + + // Ready to review: a second finished agent. + const terminals = await worktree( + "Remember the last terminal per workspace", + "feat/remember-terminal", + ); + await agent({ + provider: "codex", + workspace: terminals, + cwd: terminals.workspaceDirectory!, + title: "Remember the last terminal per workspace", + script: { + prompt: + "When I switch back to a workspace, open the terminal I was using last instead of the first one.", + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: 'rg -n "activeTerminalId" packages/app/src', + output: "", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.", + }, + ], + }, + }); + + // Plugin: the agent "built" the preview-deploys plugin this daemon runs. + const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys"); + const pluginSource = (file: string) => run(pluginDir, `cat ${file}`); + await agent({ + provider: "claude", + workspace: plugin, + cwd: plugin.workspaceDirectory!, + title: "Build a preview deploys plugin", + script: { + prompt: "Build me a plugin that shows our preview deploys right next to the composer.", + stepDelayMs: 6_000, + steps: [ + { kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "plugins/preview-deploys/paseo-plugin.json", + content: pluginSource("paseo-plugin.json"), + }, + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "plugins/preview-deploys/index.client.tsx", + content: pluginSource("index.client.tsx"), + }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "paseo plugin install ./plugins/preview-deploys", + output: "Installed preview-deploys\n", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.", + }, + ], + }, + }); + + // Hero: Claude finished a real change in a real worktree. + const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter"); + const cwd = jitter.workspaceDirectory!; + await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules")); + execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]); + const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts"); + const vitest = run( + cwd, + "npx vitest run packages/client/src/connection/reconnect-delay.test.ts", + ).replace(/^ RUN .*\n/m, ""); + const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts"); + await agent({ + provider: "claude", + workspace: jitter, + cwd, + title: "Add jitter to reconnect backoff", + script: { + prompt: + 'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.', + stepDelayMs: 7_000, + steps: [ + { + kind: "tool", + name: "Grep", + detail: { + type: "search", + toolName: "grep", + query: "reconnectAttempt", + filePaths: ["packages/client/src/daemon-client.ts"], + numFiles: 1, + numMatches: 9, + mode: "files_with_matches", + }, + }, + { + kind: "tool", + name: "Read", + detail: { + type: "read", + filePath: "packages/client/src/daemon-client.ts", + offset: 7040, + limit: 60, + content: readExcerpt, + }, + }, + { + kind: "say", + text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.", + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "packages/client/src/connection/reconnect-delay.ts", + content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"), + }, + }, + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "packages/client/src/daemon-client.ts", + unifiedDiff: clientDiff, + }, + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "packages/client/src/connection/reconnect-delay.test.ts", + content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"), + }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts", + output: vitest, + exitCode: 0, + }, + }, + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "CHANGELOG.md", + unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"), + }, + }, + { + kind: "say", + text: [ + "Reconnects now back off with jitter.", + "", + "- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.", + '- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.', + "- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.", + "", + "Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.", + ].join("\n"), + }, + ], + }, + }); + + const terminal = await client.createTerminal(cwd, "zsh", undefined, { + workspaceId: jitter.id, + command: "bash", + args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"], + }); + const terminalId = terminal.terminal?.id; + if (terminalId) { + await pause(1500); + for (const line of [ + "git status --short", + "cd packages/client", + "npx vitest run reconnect --reporter=tree", + ]) { + client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` }); + await pause(4000); + } + } + + console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`); + await writeFile( + path.join(demoRoot, "state.json"), + JSON.stringify( + { port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } }, + null, + 2, + ), + ); + const stop = async () => { + await client.close(); + await daemon.close(); + process.exit(0); + }; + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + void minute; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/assets/store-screenshots/demo/terminal-rc.sh b/assets/store-screenshots/demo/terminal-rc.sh new file mode 100644 index 0000000000..ab33d79466 --- /dev/null +++ b/assets/store-screenshots/demo/terminal-rc.sh @@ -0,0 +1,19 @@ +# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a +# phone-width terminal, and vitest output without the absolute checkout path +# or the long timing breakdown. Vitest drops its colors when piped, so the +# wrapper puts its usual green checks and grey timings back. +export FORCE_COLOR=1 +__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; } +__branch() { git branch --show-current 2>/dev/null; } +__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; } +PROMPT_COMMAND='printf "\e]0;zsh\a"' +PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] ' +npx() { + command npx "$@" 2>&1 | sed -E \ + -e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \ + -e $'s/✓/\e[32m✓\e[0m/' \ + -e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \ + -e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \ + -e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/' +} +clear diff --git a/assets/store-screenshots/output/apple-ipad/01-sidebar.png b/assets/store-screenshots/output/apple-ipad/01-sidebar.png new file mode 100644 index 0000000000..03f1d8ed61 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/01-sidebar.png differ diff --git a/assets/store-screenshots/output/apple-ipad/02-chat.png b/assets/store-screenshots/output/apple-ipad/02-chat.png new file mode 100644 index 0000000000..1c56073112 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/02-chat.png differ diff --git a/assets/store-screenshots/output/apple-ipad/03-plugins.png b/assets/store-screenshots/output/apple-ipad/03-plugins.png new file mode 100644 index 0000000000..87063634d3 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/03-plugins.png differ diff --git a/assets/store-screenshots/output/apple-ipad/04-review.png b/assets/store-screenshots/output/apple-ipad/04-review.png new file mode 100644 index 0000000000..80e8cf8a52 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/04-review.png differ diff --git a/assets/store-screenshots/output/apple-ipad/05-terminal.png b/assets/store-screenshots/output/apple-ipad/05-terminal.png new file mode 100644 index 0000000000..01b8d93c2a Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/05-terminal.png differ diff --git a/assets/store-screenshots/output/apple/01-sidebar.png b/assets/store-screenshots/output/apple/01-sidebar.png new file mode 100644 index 0000000000..f2a7ab849c Binary files /dev/null and b/assets/store-screenshots/output/apple/01-sidebar.png differ diff --git a/assets/store-screenshots/output/apple/02-chat.png b/assets/store-screenshots/output/apple/02-chat.png new file mode 100644 index 0000000000..b9afa6607d Binary files /dev/null and b/assets/store-screenshots/output/apple/02-chat.png differ diff --git a/assets/store-screenshots/output/apple/03-plugins.png b/assets/store-screenshots/output/apple/03-plugins.png new file mode 100644 index 0000000000..6f74f4ce53 Binary files /dev/null and b/assets/store-screenshots/output/apple/03-plugins.png differ diff --git a/assets/store-screenshots/output/apple/04-review.png b/assets/store-screenshots/output/apple/04-review.png new file mode 100644 index 0000000000..9b34d6cce0 Binary files /dev/null and b/assets/store-screenshots/output/apple/04-review.png differ diff --git a/assets/store-screenshots/output/apple/05-terminal.png b/assets/store-screenshots/output/apple/05-terminal.png new file mode 100644 index 0000000000..fae3170671 Binary files /dev/null and b/assets/store-screenshots/output/apple/05-terminal.png differ diff --git a/assets/store-screenshots/output/google-tablet/01-sidebar.png b/assets/store-screenshots/output/google-tablet/01-sidebar.png new file mode 100644 index 0000000000..8560682610 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/01-sidebar.png differ diff --git a/assets/store-screenshots/output/google-tablet/02-chat.png b/assets/store-screenshots/output/google-tablet/02-chat.png new file mode 100644 index 0000000000..a8f2342ab3 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/02-chat.png differ diff --git a/assets/store-screenshots/output/google-tablet/03-plugins.png b/assets/store-screenshots/output/google-tablet/03-plugins.png new file mode 100644 index 0000000000..3fe70263ca Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/03-plugins.png differ diff --git a/assets/store-screenshots/output/google-tablet/04-review.png b/assets/store-screenshots/output/google-tablet/04-review.png new file mode 100644 index 0000000000..ae54bfa798 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/04-review.png differ diff --git a/assets/store-screenshots/output/google-tablet/05-terminal.png b/assets/store-screenshots/output/google-tablet/05-terminal.png new file mode 100644 index 0000000000..9921c839dd Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/05-terminal.png differ diff --git a/assets/store-screenshots/output/google/01-sidebar.png b/assets/store-screenshots/output/google/01-sidebar.png new file mode 100644 index 0000000000..abed766b66 Binary files /dev/null and b/assets/store-screenshots/output/google/01-sidebar.png differ diff --git a/assets/store-screenshots/output/google/02-chat.png b/assets/store-screenshots/output/google/02-chat.png new file mode 100644 index 0000000000..bd42e5df49 Binary files /dev/null and b/assets/store-screenshots/output/google/02-chat.png differ diff --git a/assets/store-screenshots/output/google/03-plugins.png b/assets/store-screenshots/output/google/03-plugins.png new file mode 100644 index 0000000000..e26ecb407e Binary files /dev/null and b/assets/store-screenshots/output/google/03-plugins.png differ diff --git a/assets/store-screenshots/output/google/04-review.png b/assets/store-screenshots/output/google/04-review.png new file mode 100644 index 0000000000..38e9287c6a Binary files /dev/null and b/assets/store-screenshots/output/google/04-review.png differ diff --git a/assets/store-screenshots/output/google/05-terminal.png b/assets/store-screenshots/output/google/05-terminal.png new file mode 100644 index 0000000000..1445744fcb Binary files /dev/null and b/assets/store-screenshots/output/google/05-terminal.png differ diff --git a/assets/store-screenshots/raw/ipad/chat.png b/assets/store-screenshots/raw/ipad/chat.png new file mode 100644 index 0000000000..f8abc88b6c Binary files /dev/null and b/assets/store-screenshots/raw/ipad/chat.png differ diff --git a/assets/store-screenshots/raw/ipad/diff.png b/assets/store-screenshots/raw/ipad/diff.png new file mode 100644 index 0000000000..030946b7ae Binary files /dev/null and b/assets/store-screenshots/raw/ipad/diff.png differ diff --git a/assets/store-screenshots/raw/ipad/plugin-sheet.png b/assets/store-screenshots/raw/ipad/plugin-sheet.png new file mode 100644 index 0000000000..2550f0eecd Binary files /dev/null and b/assets/store-screenshots/raw/ipad/plugin-sheet.png differ diff --git a/assets/store-screenshots/raw/ipad/plugin.png b/assets/store-screenshots/raw/ipad/plugin.png new file mode 100644 index 0000000000..1fd3fbe2d8 Binary files /dev/null and b/assets/store-screenshots/raw/ipad/plugin.png differ diff --git a/assets/store-screenshots/raw/ipad/sidebar.png b/assets/store-screenshots/raw/ipad/sidebar.png new file mode 100644 index 0000000000..f348372425 Binary files /dev/null and b/assets/store-screenshots/raw/ipad/sidebar.png differ diff --git a/assets/store-screenshots/raw/ipad/terminal.png b/assets/store-screenshots/raw/ipad/terminal.png new file mode 100644 index 0000000000..3c65d7aabf Binary files /dev/null and b/assets/store-screenshots/raw/ipad/terminal.png differ diff --git a/assets/store-screenshots/raw/iphone/chat.png b/assets/store-screenshots/raw/iphone/chat.png new file mode 100644 index 0000000000..5ba23b3282 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/chat.png differ diff --git a/assets/store-screenshots/raw/iphone/diff.png b/assets/store-screenshots/raw/iphone/diff.png new file mode 100644 index 0000000000..eab57a68d5 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/diff.png differ diff --git a/assets/store-screenshots/raw/iphone/files.png b/assets/store-screenshots/raw/iphone/files.png new file mode 100644 index 0000000000..d2ebaf96b2 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/files.png differ diff --git a/assets/store-screenshots/raw/iphone/plugin-sheet.png b/assets/store-screenshots/raw/iphone/plugin-sheet.png new file mode 100644 index 0000000000..012e3877ea Binary files /dev/null and b/assets/store-screenshots/raw/iphone/plugin-sheet.png differ diff --git a/assets/store-screenshots/raw/iphone/plugin.png b/assets/store-screenshots/raw/iphone/plugin.png new file mode 100644 index 0000000000..95beb12d67 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/plugin.png differ diff --git a/assets/store-screenshots/raw/iphone/sidebar.png b/assets/store-screenshots/raw/iphone/sidebar.png new file mode 100644 index 0000000000..d0601f3f2f Binary files /dev/null and b/assets/store-screenshots/raw/iphone/sidebar.png differ diff --git a/assets/store-screenshots/raw/iphone/terminal.png b/assets/store-screenshots/raw/iphone/terminal.png new file mode 100644 index 0000000000..b96b192e17 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/terminal.png differ diff --git a/assets/store-screenshots/render.mjs b/assets/store-screenshots/render.mjs new file mode 100644 index 0000000000..6695eac559 --- /dev/null +++ b/assets/store-screenshots/render.mjs @@ -0,0 +1,413 @@ +// Renders the App Store and Google Play screenshots from the native captures in +// raw/. Every image is resized with Lanczos to the exact pixel size it occupies +// on the canvas, and the page renders at device scale 1, so the browser never +// resamples a capture. +// +// node assets/store-screenshots/render.mjs +// +// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts; +// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac. + +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import { chromium } from "playwright"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import sharp from "sharp"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(root, "../.."); +const raw = (device, name) => path.join(root, "raw", device, `${name}.png`); +const output = path.join(root, "output"); +const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images"); + +// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at +// 2x. The frame's screen radius and Dynamic Island are in capture pixels. +const DEVICES = { + iphone: { + capture: { width: 1320, height: 2868 }, + radius: 186, + bezel: 22, + island: { width: 378, height: 112, top: 33 }, + }, + ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null }, +}; + +// Every title wraps to two lines and every subline fits on one; the renderer +// fails otherwise, because the device is placed from that fixed text height. +// +// Each scene names its capture per device, and a device entry may override the +// title or subline where the copy depends on the form factor. +// +// A callout lifts a region of a capture out of the device. `crop` is in native +// capture pixels, `zoom` is its size relative to the device screen (capped to +// the canvas width), and the callout is centred on the spot the crop comes +// from, moved by `shift` native pixels down the screen. +const SCENES = [ + { + id: "sidebar", + backdrop: "agents", + title: "Run many coding agents at once", + body: "See which agents are working and which need you.", + iphone: { capture: "sidebar" }, + ipad: { capture: "sidebar" }, + }, + { + id: "chat", + title: "Your full dev environment on your phone", + body: "Start agents and follow every edit and command.", + iphone: { capture: "chat" }, + ipad: { capture: "chat", title: "Your full dev environment on your tablet" }, + }, + { + id: "plugins", + title: "Ask an agent to change the app itself", + body: "This Deploys sheet is a plugin an agent wrote.", + iphone: { + capture: "plugin", + callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 }, + }, + ipad: { + capture: "plugin", + body: "This Deploys panel is a plugin an agent wrote.", + callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 }, + }, + }, + { + id: "review", + title: "Review the diff before you merge", + body: "Every file the agent changed, on its own branch.", + iphone: { + capture: "diff", + callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 }, + }, + ipad: { capture: "diff" }, + }, + { + id: "terminal", + title: "A real terminal in every workspace", + body: "Run the tests yourself, on your own machine.", + iphone: { + capture: "terminal", + callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 }, + }, + ipad: { + capture: "terminal", + callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 }, + }, + }, +]; + +// `textTop` is where the text starts when the device is sized by the canvas +// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device +// by its width and centres the text and device instead. +const STORES = { + apple: { + device: "iphone", + width: 1320, + height: 2868, + textTop: 230, + titleSize: 100, + bodySize: 46, + textWidth: 1120, + }, + google: { + device: "iphone", + androidScreenshots: "phoneScreenshots", + width: 1440, + height: 2560, + textTop: 150, + titleSize: 96, + bodySize: 44, + textWidth: 1200, + }, + "apple-ipad": { + device: "ipad", + width: 2064, + height: 2752, + textTop: 200, + titleSize: 124, + bodySize: 56, + textWidth: 1400, + }, + "google-tablet": { + device: "ipad", + androidScreenshots: "tenInchScreenshots", + width: 1800, + height: 3200, + textTop: 200, + titleSize: 136, + bodySize: 60, + textWidth: 1560, + }, +}; + +const TITLE_LINE = 1.06; +const BODY_LINE = 1.3; +const BODY_GAP = 30; +// The device starts this far below the text and runs BLEED off the bottom. +const GAP = 140; +const BLEED = 40; +const MARGIN = 56; +const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" }; + +function findFont() { + const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean); + for (const dir of dirs) { + const file = path.join(dir, "SFNS.ttf"); + if (existsSync(file)) return file; + } + throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS."); +} + +// The device fills the canvas from GAP below the text to just past the bottom +// edge, so a taller canvas gets a larger device rather than a larger gap. When +// that would be wider than the canvas, the device takes the full width and the +// text and the whole device sit centred, still GAP apart. +function deviceLayout(store, device) { + const { capture, bezel } = device; + const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE; + const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel; + const widest = store.width - 2 * MARGIN - 2 * bezel; + const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest); + const screenHeight = Math.round((screenWidth * capture.height) / capture.width); + const scale = screenWidth / capture.width; + const width = screenWidth + 2 * bezel; + const height = screenHeight + 2 * bezel; + const textTop = + screenWidth < widest + ? store.height + BLEED - height - GAP - textHeight + : (store.height - height - GAP - textHeight) / 2; + const island = device.island && { + width: Math.round(device.island.width * scale), + height: Math.round(device.island.height * scale), + top: Math.round(device.island.top * scale), + }; + return { + textTop: Math.round(textTop), + left: Math.round((store.width - width) / 2), + top: Math.round(textTop + textHeight + GAP), + width, + height, + bezel, + screenWidth, + screenHeight, + scale, + radius: Math.round(device.radius * scale), + island, + }; +} + +function calloutLayout(callout, frame, store) { + const [cropX, cropY, cropWidth, cropHeight] = callout.crop; + const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth); + const width = Math.round(cropWidth * scale); + const height = Math.round(cropHeight * scale); + const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale; + const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale; + const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN)); + return { + width, + height, + left: clamp(centreX - width / 2, store.width - width), + top: clamp(centreY - height / 2, store.height - height), + }; +} + +async function resize(source, crop, width, height, file) { + let image = sharp(source); + if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] }); + await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file); + return pathToFileURL(file).href; +} + +// Agent marks scattered behind the first panel, from the homepage's featured +// agents. Each entry is [mark, centre x, centre y, size, rotation], with the +// centre as a fraction of the canvas and the size as a fraction of its width. +const AGENT_MARKS = [ + ["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14], + ["CodexIcon", 0.89, 0.1, 0.13, 12], + ["PiIcon", 0.07, 0.34, 0.1, 10], + ["OmpIcon", 0.93, 0.37, 0.11, -10], + ["OpenCodeIcon", 0.08, 0.6, 0.11, -8], + ["CursorIcon", 0.92, 0.63, 0.1, 16], + ["MuseCodeIcon", 0.07, 0.85, 0.11, 14], + ["AntigravityIcon", 0.92, 0.88, 0.12, -12], +]; + +async function loadAgentMarks(work) { + const bundle = path.join(work, "agent-icons.mjs"); + await build({ + entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")], + outfile: bundle, + bundle: true, + format: "esm", + platform: "node", + jsx: "automatic", + }); + const icons = await import(pathToFileURL(bundle).href); + return Object.fromEntries( + AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]), + ); +} + +function backdrop(store, marks) { + return AGENT_MARKS.map(([name, x, y, size, rotation]) => { + const side = Math.round(size * store.width); + const left = Math.round(x * store.width - side / 2); + const top = Math.round(y * store.height - side / 2); + return `
${marks[name]}
`; + }).join(""); +} + +function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) { + const behind = shot.backdrop === "agents" ? backdrop(store, marks) : ""; + const { island } = frame; + const notch = island + ? `
` + : ""; + const lifted = callout + ? `` + : ""; + return ` + + +${behind} +

${shot.title}

${shot.body}

+
${notch}
+${lifted}`; +} + +async function renderStore(browser, name, store, font, marks, work) { + const dir = path.join(output, name); + await mkdir(dir, { recursive: true }); + const view = await browser.newPage({ + viewport: { width: store.width, height: store.height }, + deviceScaleFactor: 1, + }); + const device = DEVICES[store.device]; + const frame = deviceLayout(store, device); + const files = []; + for (const [index, scene] of SCENES.entries()) { + const stem = `${name}-${scene.id}`; + const shot = { ...scene, ...scene[store.device] }; + const screen = await resize( + raw(store.device, shot.capture), + null, + frame.screenWidth, + frame.screenHeight, + path.join(work, `${stem}-screen.png`), + ); + const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null; + const calloutImage = callout + ? await resize( + raw(store.device, shot.callout.from), + shot.callout.crop, + callout.width, + callout.height, + path.join(work, `${stem}-callout.png`), + ) + : null; + const html = path.join(work, `${stem}.html`); + await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks })); + await view.goto(pathToFileURL(html).href); + await view.evaluate(async () => { + await document.fonts.ready; + await Promise.all(Array.from(document.images, (image) => image.decode())); + }); + await expectLines(view, "h1", 2, `${stem} title`); + await expectLines(view, "p", 1, `${stem} subline`); + const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`); + await sharp(await view.screenshot()) + .removeAlpha() + .png() + .toFile(file); + files.push(file); + console.log(path.relative(repo, file)); + } + await view.close(); + return files; +} + +async function expectLines(view, selector, expected, label) { + const lines = await view.$eval(selector, (element) => + Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)), + ); + if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`); +} + +// Google Play and F-Droid read their screenshots from the Android metadata, +// which holds links to these renders. Rewriting the links on every render keeps +// added, removed and renamed panels in step with the App Store. +async function linkAndroidScreenshots(folder, files) { + const dir = path.join(androidImages, folder); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + for (const [index, file] of files.entries()) { + await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`)); + } +} + +async function renderPreview(rows) { + const tile = 440; + const gap = 40; + const resized = []; + let top = gap; + let width = 0; + for (const files of rows) { + let left = gap; + let rowHeight = 0; + for (const file of files) { + const meta = await sharp(file).metadata(); + const height = Math.round((meta.height * tile) / meta.width); + const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer(); + resized.push({ input, left, top }); + left += tile + gap; + rowHeight = Math.max(rowHeight, height); + } + width = Math.max(width, left); + top += rowHeight + gap; + } + const file = path.join(output, "preview.png"); + await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } }) + .composite(resized) + .png() + .toFile(file); + console.log(path.relative(repo, file)); +} + +const font = findFont(); +const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-")); +const browser = await chromium.launch({ headless: true }); +try { + const marks = await loadAgentMarks(work); + const rows = []; + for (const [name, store] of Object.entries(STORES)) { + const files = await renderStore(browser, name, store, font, marks, work); + if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files); + rows.push(files); + } + await renderPreview(rows); +} finally { + await browser.close(); + await rm(work, { recursive: true, force: true }); +} diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 378a0070ea..2c2ce29403 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -12,6 +12,26 @@ initializing → idle → running → idle (or error → closed) Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket. +## Daemon restart recovery + +Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon +reopens the saved provider conversation and settings, then sends `Resume working.`. Change +`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents. +Disabling recovery consumes pending intent; enabling it later does not revive old work. + +Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived +agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle. +Explicit creation environment overrides are saved with the agent and reapplied when reopening. + +Startup chooses candidates before anything else reads or repairs persisted running state, then +opens connections and resumes every candidate in the background. A provider that is slow to reopen +holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before +closing runtimes. + +This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose +or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry +loop or exactly-once turn guarantee. + ## Runtime residency An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider @@ -25,8 +45,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. -Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle -action such as archive, replacement, reload, workspace teardown, or daemon shutdown. +Idle resumable runtimes are collected after one hour without activity, checked once a minute. +Collection releases the process and preserves the agent record, conversation, relationships, and +provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in +host config to disable automatic collection. The host setting applies immediately; config-file edits +apply on reload. Disabling invalidates pending safety checks and close admission. A close already +started finishes. Re-enabling keeps the existing idle timestamps. + +Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working +managed or native descendants prevent collection. Outstanding local or remote finish subscriptions +also protect their caller, including registration, recovery and notification dispatch acknowledgment. +An idle managed child can still own background work. + +The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false, +errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider +check, the baseline decides collection after the same one-hour idle period. Once protected work +finishes, a fresh idle hour starts. There is no deadline that overrides a veto. + +The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime. +AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown. +Finish subscriptions expose caller protection through their public capability; the collector never +reads their watches or durable records. + +The collector rechecks daemon state after asynchronous probes and event draining, in the existing +close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving +after closure starts waits for release and resumes the saved session. + +Provider checks follow the signals their runtimes expose: + +- Claude uses its process-local background roster and pending completion notifications. A fresh + process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must + demonstrate support by emitting one. Unknown versions stay resident until then. +- Codex queries background terminals for loaded threads, including native children, and protects + pending provider requests. App servers without the terminal-list RPC stay resident. +- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and + inbox inventories. The shared server remains leased while a session vetoes collection. +- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native + subagent signals also protect their parent through the daemon's subagent store. +- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`. + These protect jobs with unsuppressed completion delivery, pending deliveries, queued result + injection, and admitted or scheduled continuations that can re-wake the session. Older OMP + runtimes without either field stay resident. These signals are not an inventory of arbitrary + processes. +- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running + ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not + prevent collection. This does not inspect processes launched internally by the provider. + +These checks do not discover arbitrary detached processes or work hidden by third-party +extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup). A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and @@ -71,9 +137,41 @@ normal root agent immediately, and closing its still-open tab archives it. Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. +Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility. + Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. -`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. +`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` for explicit reporting or no completion callback. + +Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches. + +The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt +crash can lose queued prompts. A durable send receipt remains pending until actual dispatch +acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued +admission within their original timeout and cancellation scope. + +Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module +persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses +`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt +acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch +accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same +caller, child, and host into one watch. + +Bootstrap restores these watches after repairing stale running records to `closed` and before opening +ingress. Reading a closed child's result does not load its provider. A finish notification reports that +the child's run has settled; the parent decides whether its task is complete. A child chosen for restart +recovery is observed only after its resume was sent, so the parent never hears that it was closed +in between. Archived or deleted parents suppress delivery; +parent-owned creation watches also stop +reporting after detachment. + +Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure +retries the captured notification; it does not change the child's reported outcome. Remote host loss +follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining +outstanding intent. A crash after successful dispatch but before removal commits can repeat the +notification. Clean shutdown can also flush an already queued callback while retaining its intent, +so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery. + Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. @@ -212,7 +310,7 @@ parentAgentId === thisAgent.id AND !archivedAt - **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. diff --git a/docs/android.md b/docs/android.md index c90a27efec..17bafd6b24 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,39 +61,93 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted ## Local build + install +The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after +rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the +resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation +regression renders real gradient pixels across growing and shrinking layouts, including a nested +group. From `packages/app/android`, run it on your selected emulator: + +```bash +ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \ + --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` + +The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. +It backports +[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing +font size or another unhandled configuration can otherwise disable image selection +until the app process restarts. Rebuild the native app after changing this patch; +a Metro reload cannot update it. Remove the patch when the installed +`expo-modules-core` includes the fix. + +Run its native regression tests from `packages/app/android` after prebuild: + +```bash +./gradlew :expo-modules-core:testDebugUnitTest \ + --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \ + --no-daemon --max-workers=1 -Dorg.gradle.parallel=false +``` + From repo root: ```bash -npm run android:development # Debug build -npm run android:production # Release build -npm run android:clear # Remove generated Android project +npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86 +npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones +npm run dev:android # Build/install the debug app and start development +npm run dev:ios # Build/install the iOS development app ``` -For a production-ID release APK that local Android profiling tools can attach to: +`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes +`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable +`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable +flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for +personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag; +watchdog recording does not send duplicate spans to that sink by default. Normal store/release +workflows leave diagnostics disabled. + +The older `android`, `android:development`, and `ios` commands remain aliases for development. +`android:production` still builds and installs a release through Expo; use `build:apk` when you +need an artifact. `android:clear` removes the generated Android project. + +Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests. + +### Diagnostic logs without ADB + +In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all +retained `.jsonl` files. No app screen, storage permission, or attached debugger is required. +The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation +replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall +can leave older Downloads files that the new installation cannot manage. + +Each file identifies its build and session. Events include application-transport frame sizes and +message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree +render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents +are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when +already available, otherwise **UTF-16 code units** for text. These are +client transport observations, after relay decryption where applicable, not packet arrival times. + +The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval). +A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration. +Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked; +`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event +when JS resumes before the next native sampling tick. Process death leaves earlier files intact, +but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash. + +`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include +faster renders. Nested profiler timings overlap: don't add parent and child times. React's render +measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped` +means the bounded queue overflowed; missing events in that interval must not be treated as absence +of activity. Storage failure stops recording rather than blocking the app. + +For an ordinary release APK with system trace markers only: ```bash PASEO_PROFILE_BUILD=1 npm run android:production ``` -This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds -`` and enables local Android trace markers for workspace mounts -and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents, -and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`). - -Or from `packages/app`: - -```bash -# Debug -npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive -npx cross-env APP_VARIANT=development expo run:android --variant=debug - -# Release -npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive -npx cross-env APP_VARIANT=production expo run:android --variant=release - -# Clear generated Android project -rm -rf android -``` +Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`); +they do not produce Downloads files. ## Running on an emulator against a worktree daemon @@ -173,7 +227,8 @@ fastlane/metadata/android/ │ ├── images/ │ │ ├── icon.png (512x512) │ │ ├── featureGraphic.png (1024x500) -│ │ └── phoneScreenshots/ (1.png, 2.png, ...) +│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders) +│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders) │ └── changelogs/ (generated — see below) ├── ja/ └── zh-CN/ @@ -181,6 +236,8 @@ fastlane/metadata/android/ Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`. +Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots). + F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result. One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes. diff --git a/docs/architecture.md b/docs/architecture.md index 4ed25ba1c4..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a Your code never leaves your machine. Paseo is local-first. +Daemon-owned outbound connections and routed API scope ownership are described in +[Connected hosts](connected-hosts.md). + ## System overview ``` @@ -63,6 +66,9 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf retaining the daemon's runtime and wire-schema allocations. The server export tests guard these dependency trees with tree-shaking disabled to match unbundled production imports. +Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that +survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md). + Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's project Git observer keeps one non-recursive watch on each lexically equivalent active project root and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs @@ -71,6 +77,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do not retain non-Git directories. +Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store. +Workspace descriptors, checkout diff subscriptions, and script metadata read the last known +snapshot from that store, so reconnecting clients receive it before refresh completes. +Refcounted git watches feed the store through snapshot-updated events; their lifetime controls +refresh only. Session cleanup and workspace archive retain the data. Removing the final active +workspace for a directory clears it. The store lives in memory and resets on daemon restart. + **Key modules:** | Module | Responsibility | @@ -172,9 +185,9 @@ Communicates with the daemon via the same WebSocket protocol as the app. Enables remote access when the daemon is behind a firewall. -- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305 +- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag - The relay is zero-knowledge — it routes encrypted bytes and cannot read content -- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) +- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`) - Pairing via QR code transfers the daemon's public key to the client - New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot. - Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only @@ -480,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). +## Outbound networking + +The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` +(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets +share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct. +A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes +are treated as unset, with one warning naming the variable in `daemon.log`. +Agent and terminal environments are unchanged. System proxy settings and SOCKS-only +`ALL_PROXY` configurations are not supported. + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md new file mode 100644 index 0000000000..7e8b67ee62 --- /dev/null +++ b/docs/connected-hosts.md @@ -0,0 +1,107 @@ +# Connected hosts + +A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list. +Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable +`registrationId` created before B is reachable. + +## Ownership and authority + +Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe +target description. Replacement inputs never come back through a read API. Keep this file private +when collecting diagnostics or backing up daemon state. + +The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry, +including a user's disabled state. Changed input requires an explicit update. Display labels and +access can be changed by users with `tunnel.manage`; connection and metadata changes require the +producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again. +Disabling retains the opt-out. + +Each enabled registration owns one physical daemon link, shared by status monitoring and every +routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the +admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual +physical link principal, then intersects forwarded caller grants with that link's current authority. +Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with +current authority. A routed API cannot select a second hop, and saving a host grants no reverse access. + +A producer that registers a host through a routed API is qualified on B as +`route:::`, with each component percent-encoded. This namespace belongs to the authenticated link +principal. An ordinary direct B consumer does not become that producer by supplying a source key. +The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal. +Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. + +## Delegating agents + +Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`, +`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents` +accept `serverId`; omission selects the calling daemon. Remote creation requires a +destination workspace and explicit provider/model. Caller filesystem defaults apply only on the +calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for +finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them. + +The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses +the ordinary conversation on the destination's session. The runtime selects among that host's +connection candidates, including direct connections and routes discovered through another daemon. +Adding a direct connection to a discovered host adds another candidate. If no candidate connects, +the destination workspace keeps its existing disconnected or reconnecting behavior. + +Finish notifications name the destination server and child agent. The caller's daemon owns durable +subscription intent and restores observation through the same routed wait and permission observation +used during ordinary operation. Restarting the caller's daemon does not imply that a remote child +stopped. Three failed remote observation attempts end the subscription with a notification that the +child could not be observed and its status is unknown. This does not prove the host is offline: remote +RPC failures also prevent observation. Attempts use capped backoff; a connection still +in progress does not spend that budget. A confirmed removed registration reports host unavailability +immediately through the same path. Neither notice claims the child finished. Intent remains stored +until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The +parent decides whether to inspect the child or delegate again. + +A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt +response retains intent because the destination may have admitted it; observation then follows the +same retry policy. A confirmed missing child reports unavailability. + +Remote creation and local subscription persistence are separate commits. A failed local write reports +failure even if the destination already created the child; inspect it before retrying creation. +See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment, +and duplicate-delivery semantics. Concurrent prompts gain no turn attribution. + +## Connections and recovery + +The shared Node connector owns the target grammar and each connection attempt, including a fresh +SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns +physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery +status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate +registrations remain inspectable; a changed destination requires explicit identity replacement. + +Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`. +A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local +operations; requesting this feature gives update guidance. The registry retries an older destination +with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and +authentication failures stay in `error` until an update changes the connection, `enabled`, or +`replaceIdentity`, or A restarts. + +Disposing a routed API releases its observations and session. It does not remove the saved host or +affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore +through the usual owned-subscription contract. The ordinary client queues new requests while connecting +within its existing deadline and rejects requests while disconnected. Connection loss clears old +waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError` +(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination +before retrying a mutation. +Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent +creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission +RPC codes pass through unchanged, and connection/recovery behavior matches direct clients. + +Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8 +channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to +base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed +payloads reserve space for the routing header inside that limit. Relay encryption retains its own +existing frame limit. + +Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical +output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary +frame size. Awaitable output completes when the physical send completes. An overflowing channel +closes without closing siblings or the saved link. Routed sessions release resources on disconnect; +subscription demand is rebuilt by its owning client on reconnect. + +See the [SDK reference](../public-docs/sdk/reference.md#clienthosts), +[CLI reference](../public-docs/cli.md#connected-hosts), and +[plugin reference](../public-docs/plugins/reference.md#connected-hosts). diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..1d6d67d453 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -65,6 +65,7 @@ per-agent examples and provider-specific keys. ## Table of Contents - [Extending a built-in provider](#extending-a-built-in-provider) +- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server) - [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) - [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) - [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) @@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai --- +## OpenCode with an existing v2 server + +Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment: + +```json +{ + "agents": { + "providers": { + "opencode": { + "env": { + "PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096", + "PASEO_OPENCODE_SERVER_PASSWORD": "" + } + } + } + } +} +``` + +`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`. + +A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server. + +An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package. + +OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service. + +--- + ## Z.AI (Zhipu) coding plan [Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. @@ -232,7 +262,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. -Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. +Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name. ### Setup @@ -260,9 +290,9 @@ Paseo passes those variables through to the Codex app-server process **and** map Under the hood, for each custom Codex provider Paseo injects this into Codex's config: ```toml -model_provider = "my-codex" +model_provider = "paseo:my-codex" -[model_providers.my-codex] +[model_providers."paseo:my-codex"] name = "My Codex" base_url = "https://custom-relay.example.com/v1" wire_api = "responses" diff --git a/docs/data-model.md b/docs/data-model.md index 1b578bb6d8..dc65096b66 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that } }, providers: { - openai: { - apiKey?: string, - baseUrl?: string, - stt?: { apiKey?: string, baseUrl?: string }, - tts?: { apiKey?: string, baseUrl?: string } - }, local: { modelsDir: string } }, agents: { @@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that pluginsEnabled: boolean, plugins: Record, features: { - dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, - voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } + dictation: { enabled, stt: SpeechSttConfig }, + voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig } }, log: { level, format, @@ -317,7 +311,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a session is created, resumed, imported, or reloaded, so configuration changes affect the next session rather than an already-running one. -`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. +`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen). ### Git process limits @@ -353,44 +347,27 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. +### Speech configuration -Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. +Each use site owns its provider settings: `features.dictation.stt`, +`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict +`local` or `openai` variant. Both STT variants carry `language`; normalization preserves +it regardless of provider. `providers.local.modelsDir` remains shared storage. +See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence. -Set these to select OpenAI instead of local speech: +On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs +into OpenAI sites that lack their own values, using endpoint settings before shared +settings. A provider-less site becomes local; provider names are lower-cased, and +fields ignored by the selected speech provider are discarded. The next save writes only the +inline shape. A legacy key with no site to receive it is discarded with a warning +that names its path, without logging its value. -| Env var | Applies to | -| ------------------------------ | ------------------------------- | -| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider | -| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | -| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | - -OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: - -```json -{ - "providers": { - "openai": { - "stt": { - "apiKey": "sk-...", - "baseUrl": "https://stt.example.com/v1" - }, - "tts": { - "apiKey": "sk-...", - "baseUrl": "https://api.openai.com/v1" - } - } - } -} -``` - -`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools. - -Paseo uses these paths under the configured OpenAI base URL: - -- dictation STT: `/v1/audio/transcriptions` -- voice mode STT: `/v1/audio/transcriptions` -- voice mode TTS: `/v1/audio/speech` +`speech-settings.ts` owns mutable config reads and patches. The wire uses the same +`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config) +and report effective env sources in `envOverrides`. A site patch that changes +`provider` replaces the entire object. The same or omitted `provider` merges fields +into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and +options from the previous variant. --- diff --git a/docs/design.md b/docs/design.md index e21270c85d..dbf4c028ec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. -A pressable styled to look like a button is wrong; the button is `"); + await showTransientDeveloperToast(page, false); + await hideDeveloperToasts(page); +} + +async function navigateWithHiddenDeveloperToasts(page: Page) { + await hideDeveloperToasts(page); + await page.goto("data:text/html,"); + await showTransientDeveloperToast(page, true); +} + +async function inspectLayoutWithHiddenToast(page: Page) { + const action = page.getByRole("button", { name: "Continue", exact: true }); + const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox())); + for (const box of boxes) expect(box).not.toBeNull(); + await expect(page.locator("#error-toast")).toBeHidden(); + await action.click(); +} + +test("a hidden developer toast does not stall concurrent layout checks", async ({ page }) => { + test.setTimeout(10000); + await openHiddenDeveloperToast(page); + await inspectLayoutWithHiddenToast(page); +}); + +test("developer toast hiding survives navigation and Expo shadow-root updates", async ({ + page, +}) => { + test.setTimeout(10000); + await navigateWithHiddenDeveloperToasts(page); + await inspectLayoutWithHiddenToast(page); + await page.evaluate(() => { + const root = document.getElementById("error-toast")!.shadowRoot!; + const replacement = document.createElement("button"); + replacement.style.cssText = "width:32px;height:32px"; + replacement.addEventListener("click", () => replacement.remove()); + root.append(replacement); + }); + await inspectLayoutWithHiddenToast(page); +}); diff --git a/packages/app/e2e/browser/diff-performance.spec.ts b/packages/app/e2e/browser/diff-performance.spec.ts index c11c82f279..0358b6359e 100644 --- a/packages/app/e2e/browser/diff-performance.spec.ts +++ b/packages/app/e2e/browser/diff-performance.spec.ts @@ -1,6 +1,7 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test"; +import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { getServerId } from "../support/helpers/server-id"; @@ -296,8 +297,187 @@ diffPerfDescribe("Diff canvas performance", () => { await repo.cleanup().catch(() => undefined); } }); + test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => { + test.setTimeout(300_000); + const corpus = await createPullRequestCorpusRepository(); + const client = await connectSeedClient(); + + try { + const created = await client.createWorkspace({ + source: { kind: "directory", path: corpus.repo.path }, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to create wrap performance workspace"); + } + + await configureUnwrappedUnifiedDiff(page); + await instrumentDiffLatency(page); + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id)); + await waitForWorkspaceTabsVisible(page); + const openStartedAt = performance.now(); + await openChangesPanel(page, 90_000); + await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 }); + const open = { + readyMs: Math.round(performance.now() - openStartedAt), + ...(await readDiffDocumentRenders(page)), + }; + + const toggles: WrapToggleMeasurement[] = []; + for (const name of [ + "wrap on (cold)", + "wrap off (cold)", + "wrap on (warm)", + "wrap off (warm)", + ]) { + toggles.push(await measureWrapToggle(page, name)); + } + const unwrappedScroll = await continuouslyScrollCanvas(page); + toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)")); + const wrappedScroll = await continuouslyScrollCanvas(page); + + const report = { + scenario: "wrap-toggle", + sourceFiles: corpus.fileCount, + sourceLines: corpus.lineCount, + open, + toggles, + scroll: { + unwrapped: summarizeScroll(unwrappedScroll), + wrapped: summarizeScroll(wrappedScroll), + }, + }; + await attachPerformanceReport(testInfo, report); + console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`); + + const [wrapOn, wrapOff] = toggles; + expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore); + expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore); + } finally { + await client.close().catch(() => undefined); + await corpus.repo.cleanup().catch(() => undefined); + } + }); }); +interface WrapToggleMeasurement { + name: string; + /** Pointer down on the toolbar button to two painted frames after the document resized. */ + latencyMs: number; + scrollHeightBefore: number; + scrollHeightAfter: number; + longestTaskMs: number; + longTaskTotalMs: number; + diffDocumentCommits: number; + diffDocumentRenderMs: number; +} + +interface LongTask { + startTime: number; + duration: number; +} + +interface DiffLatencyScope { + __PASEO_RENDER_PROFILE_ENABLED__?: boolean; + __PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>; + __PASEO_RESET_RENDER_PROFILE__?: () => void; + __PASEO_LONG_TASKS__?: LongTask[]; + __PASEO_WRAP_TOGGLE_AT__?: number; +} + +async function createPullRequestCorpusRepository() { + const files = pullRequestCorpus(); + const repo = await createTempGitRepo("diff-wrap-perf-", { + files: files.map((file) => ({ path: file.path, content: "baseline\n" })), + }); + for (const file of files) { + await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`); + } + return { + repo, + fileCount: files.length, + lineCount: files.reduce((count, file) => count + file.lines.length, 0), + }; +} + +async function instrumentDiffLatency(page: Page): Promise { + await page.addInitScript(() => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + scope.__PASEO_RENDER_PROFILE_ENABLED__ = true; + scope.__PASEO_LONG_TASKS__ = []; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration }); + } + }).observe({ type: "longtask", buffered: true }); + }); +} + +async function readDiffDocumentRenders( + page: Page, +): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> { + return page.evaluate(() => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter( + (sample) => sample.id === "DiffDocument", + ); + return { + diffDocumentCommits: samples.length, + diffDocumentRenderMs: Math.round( + samples.reduce((total, sample) => total + sample.actualDuration, 0), + ), + }; + }); +} + +async function measureWrapToggle(page: Page, name: string): Promise { + const scroll = page.getByTestId("git-diff-scroll"); + const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight); + await page.evaluate(() => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + scope.__PASEO_RESET_RENDER_PROFILE__?.(); + scope.__PASEO_LONG_TASKS__ = []; + scope.__PASEO_WRAP_TOGGLE_AT__ = undefined; + document.addEventListener( + "pointerdown", + () => { + scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now(); + }, + { capture: true, once: true }, + ); + }); + await page.getByTestId("changes-toggle-wrap-lines").click(); + const measured = await scroll.evaluate(async (element, before) => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + const deadline = performance.now() + 240_000; + while (element.scrollHeight === before && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + const doneAt = performance.now(); + // Long task entries are delivered after the task ends; give the observer a turn. + await new Promise((resolve) => setTimeout(resolve, 50)); + const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt; + const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter( + (task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt, + ); + return { + latencyMs: Math.round(doneAt - startedAt), + scrollHeightAfter: element.scrollHeight, + longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))), + longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)), + }; + }, scrollHeightBefore); + return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) }; +} + +function summarizeScroll(report: Awaited>) { + const { frameSignatures: _frameSignatures, ...summary } = report; + return summary; +} + async function measureAdditionalPerformanceScenario( page: Page, cdp: CDPSession, diff --git a/packages/app/e2e/browser/explorer-resize-performance.spec.ts b/packages/app/e2e/browser/explorer-resize-performance.spec.ts new file mode 100644 index 0000000000..33cf48c94c --- /dev/null +++ b/packages/app/e2e/browser/explorer-resize-performance.spec.ts @@ -0,0 +1,118 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; + +interface DragMeasurement { + observer: MutationObserver; + stylesheetMutations: number; +} + +declare global { + interface Window { + __PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement; + } +} + +async function waitForResizePaint(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); +} + +async function beginResizeMeasurement(page: Page): Promise { + await page.evaluate(() => { + const stylesheet = document.getElementById("unistyles-web"); + if (!stylesheet) throw new Error("Shared stylesheet is missing"); + globalThis.__PASEO_RESET_RENDER_PROFILE__!(); + const measurement: DragMeasurement = { + stylesheetMutations: 0, + observer: new MutationObserver((records) => { + measurement.stylesheetMutations += records.length; + }), + }; + measurement.observer.observe(stylesheet, { + childList: true, + subtree: true, + characterData: true, + }); + window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement; + }); +} + +async function previewExplorerResize(page: Page, distance: number) { + const handle = page + .getByTestId("workspace-explorer-sidebar-resize-handle") + .getByRole("separator"); + const explorer = page.getByTestId("workspace-explorer-sidebar"); + const bounds = await handle.boundingBox(); + if (!bounds) throw new Error("Explorer resize handle is missing"); + const before = (await explorer.boundingBox())!.width; + const x = bounds.x + bounds.width / 2; + const y = bounds.y + 100; + await page.mouse.move(x, y); + await page.mouse.down(); + await waitForResizePaint(page); + await beginResizeMeasurement(page); + for (let move = 1; move <= 20; move++) { + await page.mouse.move(x + (distance * move) / 20, y); + await waitForResizePaint(page); + } + const during = (await explorer.boundingBox())!.width; + const measurement = await page.evaluate(() => { + const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!; + state.observer.disconnect(); + return { + stylesheetMutations: state.stylesheetMutations, + paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}) + .filter(([id]) => id.startsWith("SplitPaneView:")) + .reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0), + }; + }); + await page.mouse.up(); + await waitForResizePaint(page); + const after = (await explorer.boundingBox())!.width; + expect(during).toBeCloseTo(before - distance, 0); + expect(after).toBeCloseTo(during, 0); + return { ...measurement, width: after }; +} + +async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise { + await page.reload(); + const explorer = page.getByTestId("workspace-explorer-sidebar"); + await expect(explorer).toBeVisible(); + await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0); +} + +test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({ + page, + withWorkspace, +}) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + await page.addInitScript(() => { + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true; + }); + const workspace = await withWorkspace(); + await workspace.navigateTo(); + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + // Verify that direct pane renders are being counted before resetting them. + expect( + await page.evaluate(() => + Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) => + id.startsWith("SplitPaneView:"), + ), + ), + ).toBe(true); + const grown = await previewExplorerResize(page, -100); + const shrunk = await previewExplorerResize(page, 50); + expect(grown.stylesheetMutations).toBe(0); + expect(grown.paneRenders).toBe(0); + expect(shrunk.stylesheetMutations).toBe(0); + expect(shrunk.paneRenders).toBe(0); + await expectExplorerWidthSurvivesReload(page, shrunk.width); +}); diff --git a/packages/app/e2e/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts index c8536c884a..06a6994332 100644 --- a/packages/app/e2e/browser/explorer-sidebar.spec.ts +++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts @@ -1,3 +1,7 @@ +import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; +import { writeFile } from "node:fs/promises"; +import { openFileFromExplorer } from "../support/helpers/file-explorer"; +import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings"; import { expect, test } from "../support/fixtures"; import { gotoWorkspace, @@ -136,10 +140,9 @@ test("Explorer keeps Files and Changes close actions in the context menu", async await explorer.getByRole("button", { name: "New tab", exact: true }).click(); const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1); await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0); - await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible(); const entries = await menu.getByRole("menuitem").allTextContents(); expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan( @@ -191,13 +194,11 @@ test("Explorer keeps Files and Changes close actions in the context menu", async } }); -async function dragAgentIntoExplorer( +async function dragTabIntoExplorer( page: Parameters[0], - agentId: string, + tabId: string, ) { - const source = page - .getByTestId("workspace-pane-main") - .getByTestId(`workspace-tab-agent_${agentId}`); + const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`); const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); const sourceBox = await source.boundingBox(); const destinationBox = await destination.boundingBox(); @@ -213,7 +214,7 @@ async function dragAgentIntoExplorer( await page.mouse.up(); } -test("agents can still be dragged into Explorer when absent from its + menu", async ({ +test("moves agents into Explorer and keeps the shared Agent launcher available", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" }); @@ -230,14 +231,13 @@ test("agents can still be dragged into Explorer when absent from its + menu", as }); const main = page.getByTestId("workspace-pane-main"); await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); - await dragAgentIntoExplorer(page, agent.id); + await dragTabIntoExplorer(page, `agent_${agent.id}`); await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0); await explorer.getByRole("button", { name: "New tab", exact: true }).click(); const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); - await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1); await page.keyboard.press("Escape"); await testInfo.attach("agent-in-explorer", { body: await page.screenshot(), @@ -247,3 +247,273 @@ test("agents can still be dragged into Explorer when absent from its + menu", as await workspace.cleanup(); } }); + +test("tablet prototype keeps Explorer tabs when resizing down to a phone", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "tablet-explorer-" }); + try { + await page.setViewportSize({ width: 951, height: 669 }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await ensureExplorerSidebar(page); + await expect( + explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }), + ).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("tablet-951.png") }); + await openExplorerAgentDraft(page); + await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await revealPhoneExplorer(page); + await expect( + explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }), + ).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("phone-shared-explorer.png") }); + } finally { + await workspace.cleanup(); + } +}); + +async function openExplorerAgentDraft(page: Parameters[0]) { + await explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }).click(); + await page.getByRole("menuitem", { name: /^Agent/ }).click(); + await expect(explorerSidebar(page).getByRole("textbox").first()).toBeVisible(); +} + +async function revealPhoneExplorer(page: Parameters[0]) { + await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click(); + await expect(explorerSidebar(page)).toBeVisible(); + await expect( + explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }), + ).toBeInViewport({ ratio: 1 }); +} + +async function cycleWorkspaceTheme(page: Parameters[0]) { + await page.keyboard.press("Control+Alt+t"); +} + +test("retains the Explorer host through appearance changes and folding", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + const explorer = await ensureExplorerSidebar(page); + await openFilesPanel(page); + const host = await explorer.elementHandle(); + if (!host) throw new Error("Explorer must be mounted before changing appearance"); + const previousColor = await explorer.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + await cycleWorkspaceTheme(page); + await expect + .poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(previousColor); + expect(await host.evaluate((element) => element.isConnected)).toBe(true); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureExplorerSidebar(page); + expect(await host.evaluate((element) => element.isConnected)).toBe(true); + await page.setViewportSize({ width: 1280, height: 900 }); + await expect(explorer).toBeVisible(); + expect(await host.evaluate((element) => element.isConnected)).toBe(true); + } finally { + await workspace.cleanup(); + } +}); + +async function reorderCompactExplorerTabs(page: Parameters[0]) { + const explorer = explorerSidebar(page); + const files = explorer.getByTestId("workspace-tab-files"); + const changes = explorer.getByTestId("workspace-tab-changes_tree"); + const source = await changes.boundingBox(); + const target = await files.boundingBox(); + if (!source || !target) throw new Error("Explorer tabs must be visible to reorder"); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2); + await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 }); + await page.mouse.up(); + await expect + .poll(async () => { + const afterFiles = await files.boundingBox(); + const afterChanges = await changes.boundingBox(); + return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x); + }) + .toBe(true); +} + +test("reorders compact Explorer tabs with a pointer without changing selection", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await page.setViewportSize({ width: 390, height: 844 }); + await openFilesPanel(page); + await reorderCompactExplorerTabs(page); + await expect( + explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + } finally { + await workspace.cleanup(); + } +}); + +// Exercise appearance at its consumers while the real workspace navigator retains the panels. +test("updates populated code and diff appearance without losing the editor instance or draft", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" }); + const source = Array.from( + { length: 120 }, + (_, index) => `export const line${index} = "retained ${index}";`, + ).join("\n"); + try { + await writeFile(`${workspace.repoPath}/appearance.ts`, source); + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ codeFontSize: 13, theme: "light" }), + ); + localStorage.setItem( + "@paseo:changes-preferences", + JSON.stringify({ inlineDiff: true, layout: "unified" }), + ); + }); + await gotoWorkspace(page, workspace.workspaceId); + await openFilesPanel(page); + await openFileFromExplorer(page, "appearance.ts"); + const editor = page.getByTestId("file-source-editor").filter({ visible: true }); + const text = editor.locator(".cm-content"); + await expect(text).toBeVisible(); + await text.fill(`${source}\n// unsaved local edit`); + const instance = await editor.elementHandle(); + const scroller = editor.locator(".cm-scroller"); + await scroller.evaluate((node) => { + node.scrollTop = 400; + }); + const scrollBefore = await scroller.evaluate((node) => node.scrollTop); + await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click(); + const diff = explorerSidebar(page).getByTestId("git-diff-canvas"); + await expect(diff).toBeVisible(); + await page.keyboard.press("Control+,"); + await openSettingsSection(page, "appearance"); + await page.getByLabel("Code font size", { exact: true }).fill("19"); + await page.getByLabel("Code font size", { exact: true }).press("Tab"); + await clickSettingsBackToWorkspace(page); + await expect(text).toHaveCSS("font-size", "19px"); + expect(await instance!.evaluate((node) => node.isConnected)).toBe(true); + await expect(text).toContainText("unsaved local edit"); + expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore); + await expect(diff).toHaveCSS("font-size", "19px"); + await cycleWorkspaceTheme(page); + expect(await instance!.evaluate((node) => node.isConnected)).toBe(true); + await expect(text).toContainText("unsaved local edit"); + await dragTabIntoExplorer(page, "file_appearance.ts"); + await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible(); + const explorerEditor = await explorerSidebar(page) + .getByTestId("file-source-editor") + .elementHandle(); + await text.fill(`${source}\n// retained Explorer edit`); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureExplorerSidebar(page); + await page.setViewportSize({ width: 1280, height: 900 }); + expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true); + await expect(text).toContainText("retained Explorer edit"); + } finally { + await workspace.cleanup(); + } +}); + +test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({ + page, +}) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "explorer-chat-appearance-", + title: "Retained chat", + initialPrompt: "Show the retention marker", + featureValues: { + mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```", + }, + }); + const attaches: string[] = []; + page.on("websocket", (socket) => + socket.on("framesent", (frame) => { + if (typeof frame.payload !== "string") return; + try { + const message = JSON.parse(frame.payload).message; + if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId); + } catch { + /* Binary terminal traffic is not a JSON request. */ + } + }), + ); + try { + await openAgentRoute(page, workspace); + await ensureExplorerSidebar(page); + await dragTabIntoExplorer(page, `agent_${workspace.agentId}`); + const explorer = explorerSidebar(page); + const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true }); + await expect(paragraph).toBeVisible(); + const composer = explorer.getByRole("textbox", { name: "Message agent..." }); + await composer.fill("Unsubmitted Explorer draft"); + const composerNode = await composer.elementHandle(); + await page.keyboard.press("Control+,"); + await openSettingsSection(page, "appearance"); + await page.getByLabel("Content font size", { exact: true }).fill("20"); + await page.getByLabel("Content font size", { exact: true }).press("Tab"); + await clickSettingsBackToWorkspace(page); + await expect(paragraph).toHaveCSS("font-size", "20px"); + expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true); + await expect(composer).toHaveValue("Unsubmitted Explorer draft"); + const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color); + await cycleWorkspaceTheme(page); + await expect + .poll(() => paragraph.evaluate((node) => getComputedStyle(node).color)) + .not.toBe(previousColor); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await page + .getByRole("menuitem", { name: /^Terminal/ }) + .first() + .click(); + const terminal = explorer.getByTestId("terminal-surface"); + await expect(terminal).toBeVisible(); + await expect.poll(() => attaches.length).toBe(1); + const terminalNode = await terminal.elementHandle(); + await page.getByTestId("workspace-explorer-toggle").first().click(); + await ensureExplorerSidebar(page); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureExplorerSidebar(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await cycleWorkspaceTheme(page); + expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true); + expect(attaches).toHaveLength(1); + await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click(); + expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true); + await expect(composer).toHaveValue("Unsubmitted Explorer draft"); + await expect(paragraph).toHaveCSS("font-size", "20px"); + } finally { + await workspace.cleanup(); + } +}); + +test("compact file sheets cover Explorer and return to its retained file list", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" }); + try { + await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n"); + await gotoWorkspace(page, workspace.workspaceId); + await page.setViewportSize({ width: 390, height: 844 }); + const explorer = await ensureExplorerSidebar(page); + await openFilesPanel(page); + const host = await explorer.elementHandle(); + await openFileFromExplorer(page, "sheet-marker.txt"); + await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible(); + // A real click also proves the sheet draws above the retained Explorer overlay. + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0); + await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + expect(await host!.evaluate((node) => node.isConnected)).toBe(true); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/file-change-conflicts.spec.ts b/packages/app/e2e/browser/file-change-conflicts.spec.ts index 976139f432..cfc9ea1a55 100644 --- a/packages/app/e2e/browser/file-change-conflicts.spec.ts +++ b/packages/app/e2e/browser/file-change-conflicts.spec.ts @@ -56,45 +56,12 @@ async function replaceEditorText(page: Page, content: string): Promise { await editor.type(content); } -async function replaceFileAfterDeletionWasObserved(input: { - page: Page; - gate: Awaited>; - filePath: string; - relativePath: string; - content: string; -}): Promise { - const { page, gate, filePath, relativePath, content } = input; - // Recreate the observed race without faking file state: the daemon publishes the - // deletion, then a real read succeeds after the replacement while its ready event waits. - await gate.waitForFileSubscription(relativePath); - gate.holdFileReads(relativePath); - await unlink(filePath); - await gate.waitForHeldFileRead(); - await expect(fileCallout(page)).toHaveCount(0); - gate.releaseHeldFileRead(); - await expectOnlyFileCallout(page, "File deleted on disk"); - gate.holdNextReadyFileUpdate(relativePath); - await writeFile(filePath, content, "utf8"); - await gate.waitForHeldReadyFileUpdate(); - await expect.poll(() => readFile(filePath, "utf8")).toBe(content); -} - async function expectOnlyFileCallout(page: Page, title: string): Promise { await expect(fileCallout(page)).toHaveCount(1); await expect(fileCallout(page)).toContainText(title); await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1); } -async function expectCleanReplacementCanReload(page: Page): Promise { - await expectOnlyFileCallout(page, "Changed on disk"); - await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0); - await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0); - await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled(); - await fileCallout(page).getByRole("button", { name: "Reload" }).click(); - await expect(filePane(page).getByText("After", { exact: true })).toBeVisible(); - await expect(fileCallout(page)).toHaveCount(0); -} - async function expectDeletedFileNotice(page: Page): Promise { await expectOnlyFileCallout(page, "File deleted on disk"); await expect(fileCallout(page)).toContainText("The open copy is preserved."); @@ -140,7 +107,7 @@ test.describe("Workspace file change conflicts", () => { relativePath, content: "const preserved = true;\n", }); - await gate.waitForFileSubscription(relativePath); + await expect(visibleEditor(page)).toContainText("const preserved = true;"); await recordFileCallouts(page); await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath }); @@ -189,162 +156,80 @@ test.describe("Workspace file change conflicts", () => { relativePath, content: "const before = true;\n", }); - await gate.waitForFileSubscription(relativePath); + await expect(visibleEditor(page)).toContainText("const before = true;"); await recordFileCallouts(page); - gate.holdFileReads(relativePath); + gate.holdNextReadyFileUpdate(relativePath); await replaceEditorText(page, "const saved = true;\n"); await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;"); - await gate.waitForHeldFileRead(); + await gate.waitForHeldReadyFileUpdate(); + gate.releaseHeldReadyFileUpdate(); await expect(visibleEditor(page)).toContainText("const saved = true;"); await expect(fileCallout(page)).toHaveCount(0); await expectNoFileCalloutWasRendered(page); - gate.releaseHeldFileRead(); }); - test("a clean file replaced on disk offers one working reload action", async ({ + test("a viewed file deleted and written back shows the new file", async ({ page, withWorkspace, }) => { - const gate = await installDaemonWebSocketGate(page); const filePath = await openTrackedFile(page, withWorkspace, { prefix: "file-clean-replacement-", relativePath: "inventory.md", content: "# Before\n", }); await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); - await replaceFileAfterDeletionWasObserved({ - page, - gate, - filePath, - relativePath: "inventory.md", - content: "# After\n", - }); - gate.releaseHeldReadyFileUpdate(); - await expectCleanReplacementCanReload(page); + + await unlink(filePath); + await expectDeletedFileNotice(page); + await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); + await writeFile(filePath, "# After\n", "utf8"); + + await expect(filePane(page).getByText("After", { exact: true })).toBeVisible(); + await expect(fileCallout(page)).toHaveCount(0); }); - test("a deleted file shows one explanatory notice and no resolution actions", async ({ + test("a deleted file keeps the open copy with one notice and no resolution actions", async ({ page, withWorkspace, }) => { const filePath = await openTrackedFile(page, withWorkspace, { prefix: "file-deleted-", - relativePath: "deleted.md", - content: "# Present\n", + relativePath: "deleted.ts", + content: "const present = true;\n", }); - await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible(); + await expect(visibleEditor(page)).toContainText("const present = true;"); await recordFileCallouts(page); await unlink(filePath); await expectDeletedFileNotice(page); await expectFileCalloutWasRendered(page, "File deleted on disk"); + await expect(visibleEditor(page)).toContainText("const present = true;"); }); - test("a file check error offers a working retry", async ({ page, withWorkspace }) => { - const gate = await installDaemonWebSocketGate(page); - const relativePath = "retry.md"; - const filePath = await openTrackedFile(page, withWorkspace, { - prefix: "file-check-retry-", - relativePath, - content: "# Before\n", - }); - await gate.waitForFileSubscription(relativePath); - await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); - - await unlink(filePath); - await mkdir(filePath); - await expectOnlyFileCallout(page, "Couldn't check file on disk"); - await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled(); - await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0); - await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0); - - gate.holdNextReadyFileUpdate(relativePath); - await rm(filePath, { recursive: true }); - await writeFile(filePath, "# After\n", "utf8"); - await gate.waitForHeldReadyFileUpdate(); - gate.holdFileReads(relativePath); - await fileCallout(page).getByRole("button", { name: "Retry" }).click(); - await gate.waitForHeldFileRead(); - await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled(); - gate.releaseHeldFileRead(); - await expectCleanReplacementCanReload(page); - gate.releaseHeldReadyFileUpdate(); - }); - - test("retry clears a transient error when the file is unchanged", async ({ + test("a path that stops being a file keeps the open copy and follows the file back", async ({ page, withWorkspace, }) => { const gate = await installDaemonWebSocketGate(page); - const relativePath = "unchanged.md"; + const relativePath = "retry.ts"; const filePath = await openTrackedFile(page, withWorkspace, { - prefix: "file-check-unchanged-", + prefix: "file-check-error-", relativePath, - content: "# Unchanged\n", + content: "const before = true;\n", }); - const parkedPath = `${filePath}.parked`; - await gate.waitForFileSubscription(relativePath); - await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible(); + await expect(visibleEditor(page)).toContainText("const before = true;"); - await rename(filePath, parkedPath); + await unlink(filePath); await mkdir(filePath); - await expectOnlyFileCallout(page, "Couldn't check file on disk"); + await gate.waitForFileUpdate(relativePath, "error"); + await expect(visibleEditor(page)).toContainText("const before = true;"); - gate.holdNextReadyFileUpdate(relativePath); await rm(filePath, { recursive: true }); - await rename(parkedPath, filePath); - await gate.waitForHeldReadyFileUpdate(); - await fileCallout(page).getByRole("button", { name: "Retry" }).click(); + await writeFile(filePath, "const after = true;\n", "utf8"); + await expect(visibleEditor(page)).toContainText("const after = true;"); await expect(fileCallout(page)).toHaveCount(0); - await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0); - gate.releaseHeldReadyFileUpdate(); - }); - - test("a read-only file error preserves its message and offers retry", async ({ - page, - withWorkspace, - }) => { - const gate = await installDaemonWebSocketGate(page); - const relativePath = "artifact.bin"; - const binaryContent = new Uint8Array([0, 1, 2, 3, 255]); - const filePath = await openTrackedFile(page, withWorkspace, { - prefix: "file-read-only-retry-", - relativePath, - content: binaryContent, - }); - await gate.waitForFileSubscription(relativePath); - await expect( - filePane(page).getByText("Binary preview unavailable", { exact: true }), - ).toBeVisible(); - - await unlink(filePath); - await mkdir(filePath); - await expect( - filePane(page).getByText("Requested path is not a file", { exact: true }), - ).toBeVisible(); - await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled(); - - gate.holdNextReadyFileUpdate(relativePath); - await rm(filePath, { recursive: true }); - await writeFile(filePath, binaryContent); - await gate.waitForHeldReadyFileUpdate(); - gate.holdFileReads(relativePath); - await filePane(page).getByRole("button", { name: "Retry" }).click(); - await gate.waitForHeldFileRead(); - await expect( - filePane(page).getByText("Requested path is not a file", { exact: true }), - ).toHaveCount(0); - gate.releaseHeldFileRead(); - - await expect( - filePane(page).getByText("Binary preview unavailable", { exact: true }), - ).toBeVisible(); - await expect( - filePane(page).getByText("Requested path is not a file", { exact: true }), - ).toHaveCount(0); - gate.releaseHeldReadyFileUpdate(); }); test("a changed file with local edits offers overwrite and a working reload", async ({ diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index da729381d2..e9fc0b15ba 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { expandFolder, @@ -62,6 +63,24 @@ function htmlPreview(page: Page) { }; } +async function followHtmlPreviewLink(page: Page, name: string): Promise { + await htmlPreview(page).document.getByRole("link", { name, exact: true }).click(); +} + +async function expectPageHandlesFragment( + page: Page, + handler: "Document" | "Window", +): Promise { + const preview = htmlPreview(page); + await followHtmlPreviewLink(page, `${handler} handled jump`); + await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).not.toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe(""); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); +} + async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { const option = page.getByTestId("file-panel-bar").getByRole("button", { name: view, @@ -71,6 +90,23 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { + const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]); + expect(upperBox).not.toBeNull(); + expect(lowerBox).not.toBeNull(); + expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y); +} + +/** How many times the app has read the file's contents from the daemon. */ +function fileReadCount( + gate: Awaited>, + fileName: string, +): number { + return gate + .getClientRequests("file_explorer_request") + .filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length; +} + async function expectMarkdownImageLoaded(page: Page, name: string): Promise { const image = page.getByRole("button", { name, exact: true }); await expect(image).toBeVisible(); @@ -105,6 +141,37 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { + test("opens explicit screenshot links and explains missing files", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "screenshot-markdown-link-", + title: "Screenshot file links", + initialPrompt: "Show screenshots", + featureValues: { + mockAssistantResponse: + "[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)", + }, + }); + try { + await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true }); + await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL); + await openAgentRoute(page, session); + await test.step("Open the linked screenshot", async () => { + await openAssistantMarkdownLink(page, "Capture"); + await expectFileTabOpen(page, ".dev/tablet-review/capture.png"); + await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible(); + }); + await test.step("Explain why the missing screenshot cannot open", async () => { + await returnToAssistant(page, session.agentId); + await openAssistantMarkdownLink(page, "Missing capture"); + await expectFileTabOpen(page, ".dev/tablet-review/missing.png"); + await expect(page.getByText("File not found", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + }); + } finally { + await session.cleanup(); + } + }); + test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "encoded-markdown-link-", @@ -286,11 +353,7 @@ test.describe("CodeMirror workspace file editing", () => { title: "Unsupported large source", initialPrompt: "Generate a title and a git branch name. Return JSON only.", }); - await writeFile( - path.join(session.cwd, "too-large.txt"), - Buffer.alloc(51 * 1024 * 1024), - "utf8", - ); + await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8"); try { await openAgentRoute(page, session); @@ -553,6 +616,11 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(page.getByLabel(/File size/)).toBeVisible(); await expect(page.getByLabel(/lines/)).toBeVisible(); + await expect(page.getByTestId("file-preview-mode")).toHaveCount(0); + await expectAbove( + page.getByTestId("file-panel-bar").filter({ visible: true }), + page.getByTestId("file-source-editor").filter({ visible: true }), + ); await replaceEditorText(page, "const autosaved = 2;\n"); await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible(); @@ -654,6 +722,7 @@ test.describe("CodeMirror workspace file editing", () => { withWorkspace, }) => { test.setTimeout(90_000); + const gate = await installDaemonWebSocketGate(page); const workspace = await withWorkspace({ prefix: "file-editing-preview-" }); const markdownPath = path.join(workspace.repoPath, "notes.md"); const imagePath = path.join(workspace.repoPath, "pixel.png"); @@ -663,17 +732,30 @@ test.describe("CodeMirror workspace file editing", () => { await openWorkspaceFile(page, "notes.md"); const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true }); await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible(); + await expect(toolbar.getByLabel(/^File size/)).toBeVisible(); + await expect(toolbar.getByLabel("2 lines")).toBeVisible(); await writeFile(markdownPath, "# Updated heading\n", "utf8"); await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible(); + // Source shows the same read in the editor; the file is not read again. + const reads = fileReadCount(gate, "notes.md"); await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(editor(page)).toContainText("# Updated heading"); + await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible(); + expect(fileReadCount(gate, "notes.md")).toBe(reads); + + // Preview shows the edits made in Source. await replaceEditorText(page, "# Saved from source\n"); - await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); await selectFileView(page, "Preview"); await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible(); + await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0); + await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible(); await openWorkspaceFile(page, "pixel.png"); const image = visibleFilePane.locator("img"); @@ -762,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => { await expect(preview.host).toBeVisible(); }); + test("keeps HTML fragment links in the preview and scrolls to the target", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragments-" }); + await writeFile( + path.join(workspace.repoPath, "fragments.html"), + ` +Jump to section +
+

Target section

+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "fragments.html"); + + const preview = htmlPreview(page); + await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") }); + await preview.document.getByRole("link", { name: "Jump to section" }).click(); + + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section"); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan( + 0, + ); + await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") }); + }); + + test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-handler-" }); + await writeFile( + path.join(workspace.repoPath, "handled.html"), + ` +Handled jump +Waiting for click +

Target section

+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "handled.html"); + + const preview = htmlPreview(page); + await preview.document.getByRole("link", { name: "Handled jump" }).click(); + await expect(preview.document.locator("output")).toHaveText("Page handled click"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).not.toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe(""); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); + }); + + test("respects delegated HTML fragment handlers and normal URL parsing", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" }); + await writeFile( + path.join(workspace.repoPath, "defaults.html"), + ` + +

Target section

+

Percent target

+
+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "defaults.html"); + + const preview = htmlPreview(page); + await expectPageHandlesFragment(page, "Document"); + await expectPageHandlesFragment(page, "Window"); + + await followHtmlPreviewLink(page, "Whitespace jump"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + await followHtmlPreviewLink(page, "Percent jump"); + await expect( + preview.document.getByRole("heading", { name: "Percent target" }), + ).toBeInViewport(); + }); + + test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-targets-" }); + await writeFile( + path.join(workspace.repoPath, "targets.html"), + ` + +

Target section

+
Named target +

Encoded target

+
+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "targets.html"); + + const preview = htmlPreview(page); + const body = preview.document.locator("body"); + const nested = preview.document.getByText("Nested jump", { exact: true }); + await nested.click(); + await expect(preview.document.locator("output").first()).toHaveText("#section"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)"); + + await body.evaluate(() => window.scrollTo(0, 0)); + await nested.click(); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + + await preview.document.getByRole("link", { name: "Named jump" }).click(); + await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport(); + await expect(preview.document.locator("output").first()).toHaveText("#legacy"); + + await preview.document.getByRole("link", { name: "Encoded jump" }).click(); + await expect( + preview.document.getByRole("heading", { name: "Encoded target" }), + ).toBeInViewport(); + await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section"); + const scrollY = await body.evaluate(() => window.scrollY); + await preview.document.getByRole("link", { name: "Missing jump" }).click(); + expect(await body.evaluate(() => location.hash)).toBe("#missing"); + expect(await body.evaluate(() => window.scrollY)).toBe(scrollY); + + await preview.document.getByRole("link", { name: "Empty jump" }).click(); + await expect(preview.document.locator("output").first()).toHaveText("(empty)"); + expect(await body.evaluate(() => window.scrollY)).toBe(0); + + for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) { + await nested.click({ modifiers: [modifier] }); + await expect(preview.document.locator("#modified")).toHaveText("false"); + } + await nested.click({ button: "middle" }); + await expect(preview.document.locator("#modified")).toHaveText("false"); + expect(await body.evaluate(() => location.hash)).toBe(""); + expect(await body.evaluate(() => window.scrollY)).toBe(0); + }); + test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => { test.setTimeout(90_000); const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" }); diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index faf7285a62..b64da21bd8 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer", await entry("renamed.txt").click({ button: "right" }); const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/); - const deleteLabelColor = await deleteAction - .getByText("Delete", { exact: true }) - .evaluate((element) => getComputedStyle(element).color); - await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor); + await expect + .poll(async () => { + const labelColor = await deleteAction + .getByText("Delete", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + const iconColor = await deleteAction + .locator("svg") + .evaluate((element) => getComputedStyle(element).stroke); + return labelColor !== "" && iconColor === labelColor; + }) + .toBe(true); const cancelledConfirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index 8c1339a8c6..358c579357 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -2,6 +2,63 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + expectFileTabOpen, + openFileExplorer, + openFileFromExplorer, +} from "../support/helpers/file-explorer"; +import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs"; + +async function openMarkdownPreview(page: Page) { + await openFileExplorer(page); + await openFileFromExplorer(page, "README.md"); + await expectFileTabOpen(page, "README.md"); + await page + .getByTestId("file-panel-bar") + .getByRole("button", { name: "Preview", exact: true }) + .click(); + await ensureWorkspaceAgentPaneVisible(page); +} + +async function followPreviewLink(page: Page, name: string) { + await page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByRole("link", { name, exact: true }) + .click(); +} + +test("Markdown preview folder links reveal Files and file links still open a file", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "preview-folder-links-" }); + await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true }); + await writeFile( + path.join(workspace.repoPath, "docs/guides/guide.md"), + "# Folder link destination\n", + ); + await writeFile( + path.join(workspace.repoPath, "README.md"), + "[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n", + ); + await workspace.navigateTo(); + + await openMarkdownPreview(page); + await followPreviewLink(page, "Guide"); + await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible(); + + await openMarkdownPreview(page); + await followPreviewLink(page, "Dot"); + await expectFolderContents(page); + await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0); + + await openMarkdownPreview(page); + await followPreviewLink(page, "Bare"); + await expectFolderContents(page); + await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") }); +}); async function openReportedFolderLink(page: Page, name: string) { const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true }); @@ -24,6 +81,8 @@ for (const folder of [ "docs/guides", "docs/guides/", "docs/指南", + "examples/next.js/", + "examples/next.js", ]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ @@ -35,6 +94,9 @@ for (const folder of [ try { await mkdir(path.join(session.cwd, folder), { recursive: true }); await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n"); + const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, "")); + await mkdir(path.dirname(competingFile), { recursive: true }); + await writeFile(competingFile, "This is a file, not the requested folder.\n"); await session.client.waitForFinish(session.agentId, 30_000); await page.addInitScript(() => { localStorage.setItem( diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 788a6f93ec..fe2f57c67a 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = await test.step("the selected row imports and opens the hydrated transcript", async () => { await flow.importSession(scenario.importSessionId); await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready."); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); await capture(page, testInfo, "08-mobile-agent-after-import.png"); }); @@ -228,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page }); }); +test("large import lists keep mounted rows bounded and remain scrollable", async ({ + page, +}, testInfo) => { + const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture"); + await mkdir(fixtureDirectory, { recursive: true }); + try { + const newest = Date.now(); + for (let index = 0; index < 200; index += 1) { + const id = `virtualized-${String(index).padStart(3, "0")}`; + const file = path.join(fixtureDirectory, `${id}.jsonl`); + await writeFile( + file, + `${JSON.stringify({ + type: "user", + sessionId: id, + cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory, + message: { + role: "user", + content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`, + }, + })}\n`, + ); + const timestamp = new Date(newest - index * 10); + await utimes(file, timestamp, timestamp); + } + for (const width of [1280, 390]) { + const flow = new ImportSessionFlow(page); + await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 }); + await flow.openGlobally(); + const rows = page.locator('[data-testid^="import-session-session-"]'); + const scrollToEnd = async (end = true) => { + // Scroll the body's real overflow element. Virtual rows need not exist yet. + await rows.first().evaluate((row, atEnd) => { + let element = row.parentElement; + while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) { + element = element.parentElement; + } + if (!element) throw new Error("Import list has no scroll owner"); + element.scrollTop = atEnd ? element.scrollHeight : 0; + element.dispatchEvent(new Event("scroll")); + }, end); + }; + for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) { + const loadMore = page.getByTestId("import-session-load-more"); + await expect(async () => { + await scrollToEnd(); + await expect(loadMore).toBeInViewport({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await loadMore.click(); + if (pageIndex < 2) await expect(loadMore).toBeEnabled(); + } + await expect(async () => { + await scrollToEnd(); + await expect( + page.getByTestId("import-session-session-claude-virtualized-199"), + ).toBeInViewport({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await expect.poll(() => rows.count()).toBeLessThan(100); + await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) }); + if (width === 1280) { + const row = (index: number) => + page.getByTestId( + `import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`, + ); + await expect(async () => { + await scrollToEnd(false); + await expect(row(0)).toBeInViewport({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await row(0).focus(); + for (let index = 1; index <= 30; index += 1) { + await page.keyboard.press("Tab"); + await expect(row(index)).toBeFocused(); + } + for (let index = 29; index >= 15; index -= 1) { + await page.keyboard.press("Shift+Tab"); + await expect(row(index)).toBeFocused(); + } + const list = page.getByTestId("import-session-list"); + const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => { + const listener = await list.evaluateHandle((node, eventName) => { + const promise = new Promise((resolve) => { + node.addEventListener(eventName, () => resolve(), { once: true }); + }); + return { promise }; + }, event); + try { + await page.keyboard.press(key); + await listener.evaluate((value) => value.promise); + } finally { + await listener.dispose(); + } + }; + const beforePageDown = await list.evaluate((node) => node.scrollTop); + await scrollWithKey("scroll", "PageDown"); + await expect + .poll(() => list.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(beforePageDown); + await expect(row(15)).toBeFocused(); + await page.keyboard.press("End"); + await expect(row(15)).toBeFocused(); + await expect(row(199)).toBeInViewport(); + // Home must remain at the first row after the native page scroll ends. + await scrollWithKey("scrollend", "Home"); + await expect(row(0)).toBeInViewport(); + await expect(row(15)).toBeFocused(); + + await page.keyboard.press("End"); + await expect(row(199)).toBeInViewport(); + await row(199).focus(); + await scrollWithKey("scroll", "PageUp"); + await page.keyboard.press("Home"); + await expect(row(0)).toBeInViewport(); + await scrollWithKey("scrollend", "End"); + await expect(row(199)).toBeInViewport(); + await expect(row(199)).toBeFocused(); + await testInfo.attach("keyboard-scroll-owner", { + body: JSON.stringify( + await list.evaluate((node) => ({ + focusedRow: document.activeElement?.getAttribute("data-testid"), + scrollTop: node.scrollTop, + scrollHeight: node.scrollHeight, + clientHeight: node.clientHeight, + })), + ), + contentType: "application/json", + }); + await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") }); + await page.keyboard.press("Home"); + await expect(row(0)).toBeInViewport(); + } + await flow.close(); + } + } finally { + await rm(fixtureDirectory, { recursive: true, force: true }); + } +}); + async function capture(page: Page, testInfo: TestInfo, name: string): Promise { const outputPath = testInfo.outputPath(name); await page.screenshot({ path: outputPath, fullPage: true }); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index e9d43ced27..a34aa259d2 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures"; import type { Locator } from "@playwright/test"; import { gotoWorkspace, - assertNewChatTileVisible, assertNewTabMenuTriggerVisible, - assertSingleNewTabButton, openNewTabMenuWithShortcut, clickNewChat, clickNewTerminal, @@ -126,6 +124,7 @@ test.describe("Tab creation", () => { page, }) => { await gotoWorkspace(page, workspace.workspaceId); + await assertNewTabMenuTriggerVisible(page); await pressNewTabShortcut(page); const newTabs = page .locator('[data-testid^="workspace-tab-tab_"]') @@ -190,11 +189,18 @@ test.describe("Tab creation", () => { await expect(agent).toBeFocused(); }); - test("clicking new agent tab creates a draft tab", async ({ page }) => { + test("the Agent menu creates a draft and shows its composer within the visual budget", async ({ + page, + }) => { await gotoWorkspace(page, workspace.workspaceId); - await clickNewChat(page); - + const elapsed = await measureTileTransition( + page, + () => clickNewChat(page), + composerLocator(page), + 10_000, + ); + expect(elapsed).toBeLessThan(3_000); await expectComposerVisible(page); const tabsAfter = await getTabTestIds(page); @@ -202,12 +208,17 @@ test.describe("Tab creation", () => { expect(draftCountAfter).toBeGreaterThanOrEqual(1); }); - test("clicking terminal button creates a standalone terminal", async ({ page }) => { + test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => { test.setTimeout(45_000); await gotoWorkspace(page, workspace.workspaceId); - await clickNewTerminal(page); - + const elapsed = await measureTileTransition( + page, + () => clickNewTerminal(page), + terminalSurfaceLocator(page), + 20_000, + ); + expect(elapsed).toBeLessThan(5_000); await expectTerminalSurfaceVisible(page); const tabsAfter = await getTabTestIds(page); @@ -295,13 +306,6 @@ test.describe("Tab creation", () => { await profileSeed.restore(); } }); - - test("tab bar shows action buttons per pane", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - await assertSingleNewTabButton(page); - await assertNewChatTileVisible(page); - await assertNewTabMenuTriggerVisible(page); - }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => { expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2); await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 }); }); - - test("Terminal transition completes within visual budget", async ({ page }) => { - test.setTimeout(30_000); - await gotoWorkspace(page, workspace.workspaceId); - - const elapsed = await measureTileTransition( - page, - () => clickNewTerminal(page), - terminalSurfaceLocator(page), - 20_000, - ); - - // Terminal surface should appear within a reasonable budget. - // Note: terminal creation involves a server round-trip, so we allow more time - // than a pure in-memory transition, but it should still be well under 5 seconds. - expect(elapsed).toBeLessThan(5_000); - }); - - test("New agent tab click shows composer without flash", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - - const elapsed = await measureTileTransition( - page, - () => clickNewChat(page), - composerLocator(page), - 10_000, - ); - - // Draft creation is fully in-memory — should be fast - // We use a generous budget here because CI can be slow, but the key assertion - // is that no blank/flash frame appears (tested above). - expect(elapsed).toBeLessThan(3_000); - }); }); diff --git a/packages/app/e2e/browser/markdown-relative-links.spec.ts b/packages/app/e2e/browser/markdown-relative-links.spec.ts new file mode 100644 index 0000000000..0d84d00390 --- /dev/null +++ b/packages/app/e2e/browser/markdown-relative-links.spec.ts @@ -0,0 +1,47 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { + expandFolder, + openFileExplorer, + openFileFromExplorer, +} from "../support/helpers/file-explorer"; + +async function openNestedGuide(page: Page): Promise { + await openFileExplorer(page); + await expandFolder(page, "docs"); + await openFileFromExplorer(page, "guide.md"); +} + +async function followDetailsLink(page: Page): Promise { + await page + .getByTestId("markdown-preview-frame") + .getByRole("link", { name: "Details", exact: true }) + .click(); +} + +test("Markdown preview resolves a relative link from the document directory", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "markdown-relative-links-" }); + await mkdir(path.join(workspace.repoPath, "docs/guide"), { recursive: true }); + await writeFile( + path.join(workspace.repoPath, "docs/guide.md"), + "# Guide\n\n[Details](guide/details.md)\n", + ); + await writeFile( + path.join(workspace.repoPath, "docs/guide/details.md"), + "# Nested details destination\n", + ); + await workspace.navigateTo(); + + await openNestedGuide(page); + await followDetailsLink(page); + + await expect( + page + .getByTestId("markdown-preview-frame") + .getByText("Nested details destination", { exact: true }), + ).toBeVisible(); +}); diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index 906efcfb42..01fd4638eb 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,3 +1,4 @@ +import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory"; import { composerLocator, enterComposerFullscreen, @@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => { const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); try { + await startWithoutRememberedModel(page); await gotoAppShell(page); await waitForSidebarHydration(page); await openNewWorkspaceComposer(page, { @@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => { projectDisplayName: project.projectDisplayName, }); + await chooseModel(page, "mock", "Ten second stream"); const composer = page.getByRole("textbox", { name: "Message agent..." }); const createButton = page.getByTestId("workspace-create-submit"); await expect(composer).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts index e6c788a8f4..90c9e19964 100644 --- a/packages/app/e2e/browser/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => { } }); - test("the New Workspace screen hides the host selector when there is only one host", async ({ + test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({ page, }) => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" }); @@ -120,7 +120,16 @@ test.describe("New workspace entry points", () => { await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({ timeout: 30_000, }); - await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0); + const hostTrigger = page.getByTestId("host-picker-trigger"); + await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 }); + const originalUrl = page.url(); + await hostTrigger.click(); + await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible(); + await page.getByTestId("new-workspace-host-picker-add-host").click(); + await expect( + page.getByRole("button", { name: "Direct connection", exact: true }), + ).toBeVisible(); + await expect(page).toHaveURL(originalUrl); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index d393966ee8..7268f7146b 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -247,9 +247,7 @@ test.describe("New workspace: fullscreen composer across the compact breakpoint" await expectNewWorkspaceDraft(page, `${draft} wide`); }); - // Terminal mode and its prompt are screen state, and the shell remounts the screen - // at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone - // layout only. + // Exercise the terminal prompt's fullscreen and compact launch sheet on the phone. await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => { await resizeComposerViewport(page, "portrait"); // The compact launch control opens a sheet, not the desktop menu. diff --git a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts new file mode 100644 index 0000000000..3ed0755fc9 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts @@ -0,0 +1,100 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + delayBrowserAgentCreatedStatus, + openNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +// The creation response carries the descriptor captured when the workspace was provisioned. With an +// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's +// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The +// late response must not put the old, unpinned row back. + +function pinnedRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise { + let createdId: string | undefined; + await expect + .poll( + async () => { + const { entries } = await seeded.client.fetchWorkspaces({ + filter: { projectId: seeded.projectId }, + }); + createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id; + return createdId; + }, + { timeout: 30_000 }, + ) + .toBeTruthy(); + return createdId!; +} + +// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts. +async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise> { + await page.evaluate((rowTestId) => { + const state = { missingFrames: 0, stopped: false }; + (window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state; + const sample = () => { + if (state.stopped) return; + const section = document.querySelector('[data-testid="sidebar-pinned-section"]'); + if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, testId); + return () => + page.evaluate(() => { + const state = ( + window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } } + ).__pinnedRowWatch; + state.stopped = true; + return state.missingFrames; + }); +} + +test.describe("New workspace live pin", () => { + test("keeps a pin made before the creation response arrives", async ({ page }) => { + test.setTimeout(120_000); + const creation = await delayBrowserAgentCreatedStatus(page); + const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, seeded); + await submitNewWorkspacePrompt(page, "Pin me while I start"); + + await creation.waitForCreateRequest(); + // The response is held here, carrying the descriptor from before the pin. + await creation.waitForDelayedCreatedStatus(); + const workspaceId = await findCreatedWorkspaceId(seeded); + const rowTestId = pinnedRowTestId(workspaceId); + + await seeded.client.setWorkspacePinned(workspaceId, true); + const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId); + await expect(pinnedRow).toBeVisible({ timeout: 10_000 }); + + const stopWatching = await watchPinnedRow(page, rowTestId); + creation.release(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({ + timeout: 30_000, + }); + // Leave time for the released response to be merged and rendered. + await page.waitForTimeout(1_000); + + expect(await stopWatching()).toBe(0); + await expect(pinnedRow).toBeVisible(); + } finally { + creation.release(); + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/offline-draft.spec.ts b/packages/app/e2e/browser/offline-draft.spec.ts new file mode 100644 index 0000000000..36ae726457 --- /dev/null +++ b/packages/app/e2e/browser/offline-draft.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "../support/fixtures"; +import { expectComposerModel } from "../support/helpers/agent-profiles"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs"; +import { submitMessage } from "../support/helpers/composer"; + +async function restoreHostConnection(gate: Awaited>) { + gate.holdNextServerMessage("status"); + gate.restore(); + await gate.waitForHeldServerMessage("status"); + gate.releaseHeldServerMessage("status"); +} + +test("an offline workspace draft reports the disconnected host instead of loading models", async ({ + page, + withWorkspace, +}) => { + const gate = await installDaemonWebSocketGate(page); + const workspace = await withWorkspace(); + await workspace.navigateTo(); + await gate.drop(); + await gate.waitForBlockedConnection(); + await createAgentTabFromMenu(page); + await submitMessage(page, "hello"); + await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText( + "Host is not connected", + ); + await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0); + await restoreHostConnection(gate); + await expectComposerModel(page, "Ten second stream"); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello"); +}); diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 17f4db2e85..5f1f36e409 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -1,29 +1,90 @@ import { randomUUID } from "node:crypto"; -import { submitMessage, expectComposerEditable } from "../support/helpers/composer"; import { focusTerminalSurface, typeInTerminal, getTerminalBufferText, } from "../support/helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; -import { metroTest as test, expect } from "../support/fixtures"; +import { metroTest as test, expect, type Page } from "../support/fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; import { - expectTimelinePromptVisible, + expectFoldedTurnAtReadingPosition, + expectOldestLoadedTurnPartial, holdOlderHistoryPages, openAgentTimeline, - scrollThroughOlderHistoryPages, + expectTimelinePromptVisible, + userScrollsTimelineToHistoryStart, } from "../support/helpers/timeline-pagination"; -test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ +interface PublishedDaemonAgent { + serverId: string; + daemon: Awaited>; + agent: MockAgentWorkspace; + /** Register the host in the browser and apply app settings before the first load. */ + seedBrowser(page: Page, appSettings?: Record): Promise; + close(): Promise; +} + +async function startPublishedDaemonAgent(options: { + publishedVersion: string; + serverIdPrefix: string; + repoPrefix: string; + title: string; +}): Promise { + const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const daemon = await startIsolatedHostDaemon(serverId, { + publishedVersion: options.publishedVersion, + }); + const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port }); + const createdAgent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: options.title, + modeId: "load-test", + model: "ten-second-stream", + }); + const agent: MockAgentWorkspace = { + agentId: createdAgent.id, + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; + return { + serverId, + daemon, + agent, + async seedBrowser(page, appSettings) { + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript( + ({ seededHost, preferences, settings }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings }, + ); + }, + async close() { + await agent.cleanup(); + await daemon.close(); + }, + }; +} + +test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({ page, }) => { test.setTimeout(120_000); - const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; - const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); const observationSockets = new Set(); page.on("websocket", (socket) => { socket.on("framesent", ({ payload }) => { @@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis observationSockets.add(socket); }); }); - const workspace = await seedWorkspace({ + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.2.5", + serverIdPrefix: "srv_old_pagination_", repoPrefix: "timeline-old-daemon-pagination-", - port: daemon.port, - }); - const createdAgent = await workspace.client.createAgent({ - provider: "mock", - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, title: "Published daemon pagination regression", - modeId: "load-test", - model: "ten-second-stream", }); - const agent: MockAgentWorkspace = { - agentId: createdAgent.id, - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - client: workspace.client, - cleanup: workspace.cleanup, - }; + const { agent, serverId } = published; try { - for (let index = 0; index < 40; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } - await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); - await agent.client.waitForFinish(agent.agentId, 20_000); - for (let index = 0; index < 20; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } + await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await published.seedBrowser(page); - const host = buildSeededHost({ - serverId, - endpoint: `127.0.0.1:${daemon.port}`, - nowIso: new Date().toISOString(), - }); - await page.addInitScript( - ({ seededHost, preferences }) => { - localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); - }, - { seededHost: host, preferences: buildCreateAgentPreferences() }, - ); - - const history = await holdOlderHistoryPages(page, agent, daemon.port); await openAgentTimeline(page, agent, serverId); - await expectTimelinePromptVisible( - page, - "timeline-pagination-turn-19: emit 1 coalesced agent stream updates", - ); - await scrollThroughOlderHistoryPages(page, 3, history); - - history.expectRepeatedEntries(); - await history.expectOwnedTextRendered("Now I have a clearer picture."); + await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); expect(observationSockets.size).toBe(1); - await test.step("send a live turn after reconnecting the old daemon", async () => { - await daemon.restart(); - await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2); - await expectComposerEditable(page); - await submitMessage( - page, - "compatibility-after-reconnect: emit 1 coalesced agent stream updates", - ); - await expectTimelinePromptVisible( - page, - "compatibility-after-reconnect: emit 1 coalesced agent stream updates", - ); - // The browser and the seed client reconnect independently after the restart. - await agent.client.connect(); - await agent.client.waitForFinish(agent.agentId, 20_000); - await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); + await test.step("reconnect the browser and reload the old daemon history", async () => { + observationSockets.clear(); + await page.reload(); + await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); + await expect.poll(() => observationSockets.size).toBe(1); }); await test.step("open a terminal and receive its output", async () => { const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); expect(terminal.error).toBeNull(); const terminalId = terminal.terminal!.id; - const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + const route = buildHostWorkspaceRoute(serverId, agent.workspaceId); await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); await focusTerminalSurface(page); await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); await expect .poll(() => getTerminalBufferText(page), { timeout: 15_000 }) .toContain("compatibility-terminal-output"); - await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") }); await agent.client.killTerminal(terminalId); }); } finally { - await agent.cleanup(); - await daemon.close(); + await published.close(); + } +}); + +// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn +// starts mid-turn and only folds once the next page completes it; the reader must stay +// with that turn instead of landing at history start and draining every older page. +test("keeps the reader on the turn that folds when an older item page completes it", async ({ + page, +}) => { + test.setTimeout(240_000); + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.13.0-beta.2", + serverIdPrefix: "srv_old_fold_", + repoPrefix: "timeline-old-daemon-fold-", + title: "Published daemon folded turn pagination", + }); + const { agent, serverId } = published; + const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`); + + try { + for (const prompt of prompts) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + } + await published.seedBrowser(page, { chatDisplay: "collapseTurns" }); + const history = await holdOlderHistoryPages(page, agent, { + daemonPort: published.daemon.port, + }); + await openAgentTimeline(page, agent, serverId); + await expectTimelinePromptVisible(page, prompts.at(-1)!); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts); + + history.releasePage(1); + await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt); + await history.expectSettledWithRequestedPages(1); + } finally { + await published.close(); } }); diff --git a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts index 87afd783a4..597a1ebd54 100644 --- a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts +++ b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts @@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => { expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"], }); await captureToolCall(page, testInfo, { - label: "Send agent prompt", + label: "Send message", artifact: "send-prompt-presentation", expectedText: ["Prompt", GREETING, "Agent", "Result"], }); diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts index 5dcf2a2cec..7faae09dca 100644 --- a/packages/app/e2e/browser/plan-card-markdown.spec.ts +++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts @@ -188,3 +188,121 @@ async function measureInlineCode(message: Locator) { return { codes, right: paragraph.getBoundingClientRect().right }; }); } + +test("inline-code paths preserve stable recognition and file navigation", async ({ + page, + context, +}, testInfo) => { + const { writeFile, mkdir } = await import("node:fs/promises"); + const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"]; + const source = + paths.map((path) => `Open \`${path}\`.`).join("\n\n") + + "\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain."; + const session = await seedMockAgentWorkspace({ + repoPrefix: "inline-path-regression-", + title: "Inline path regression", + initialPrompt: "Show file paths", + featureValues: { mockAssistantResponse: source }, + }); + try { + await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true }); + for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`); + await writeFile( + `${session.cwd}/src/index.ts`, + Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"), + ); + await session.client.waitForFinish(session.agentId, 30_000); + await openAgentRoute(page, session); + const message = page.getByTestId("assistant-message"); + await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible(); + await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0); + await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0); + for (const path of paths) { + await openInlineRegressionFile(page, path); + await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText( + `Opened ${path}`, + ); + } + await openInlineRegressionFile(page, "src/index.ts#L10-L20"); + await expect(page.getByLabel("Line 20, column 26")).toBeVisible(); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page + .getByTestId("file-source-editor") + .filter({ visible: true }) + .locator(".cm-content") + .focus(); + await page.keyboard.press("ControlOrMeta+c"); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe( + Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"), + ); + await expect( + page + .getByTestId("file-source-editor") + .filter({ visible: true }) + .locator(".cm-selectionBackground"), + ).not.toHaveCount(0); + await testInfo.attach("inline-path-line-selection", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await session.cleanup(); + } +}); + +async function openInlineRegressionFile(page: Page, path: string) { + await page.getByRole("button", { name: "Inline path regression", exact: true }).click(); + await page + .getByTestId("assistant-message") + .getByRole("link", { name: path, exact: true }) + .click(); +} + +// Web links are styled inline by the library, so hover state has to restyle the anchor. +test("markdown links underline on hover", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "markdown-link-hover-", + title: "Markdown link hover", + featureValues: { + mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).", + }, + initialPrompt: "Show links", + }); + try { + await session.client.waitForFinish(session.agentId, 30_000); + await openAgentRoute(page, session); + const message = page.getByTestId("assistant-message"); + for (const name of ["the docs", "README"]) { + const link = message.getByRole("link", { name, exact: true }); + await expect(link).toHaveCSS("text-decoration-line", "none"); + await link.hover(); + await expect(link).toHaveCSS("text-decoration-line", "underline"); + await page.mouse.move(0, 0); + await expect(link).toHaveCSS("text-decoration-line", "none"); + } + } finally { + await session.cleanup(); + } +}); + +const PIXEL_PNG = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + +test("Markdown images show a zoom-in cursor", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "markdown-image-cursor-", + title: "Markdown image cursor", + featureValues: { mockAssistantResponse: `Before.\n\n![pixel](${PIXEL_PNG})\n\nAfter.` }, + initialPrompt: "Show image", + }); + try { + await session.client.waitForFinish(session.agentId, 30_000); + await openAgentRoute(page, session); + const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" }); + await expect(image).toHaveCSS("cursor", "zoom-in"); + } finally { + await session.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index 22eb61910b..fe4227a8c3 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -1,15 +1,24 @@ +import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations"; import { test } from "../support/fixtures"; import { withButtonShowcase } from "../support/helpers/plugin-buttons"; test("plugin header and composer buttons share actions, menus, content, and updates", async ({ page, -}) => { - test.setTimeout(180_000); - await withButtonShowcase(page, async (buttons) => { +}, info) => { + test.setTimeout(240_000); + await withButtonShowcase(page, info, async (buttons) => { await buttons.verifyObservationLifetime(); await buttons.openWideMenusAndPopovers(); await buttons.runAndUpdateActions(); await buttons.openCompactSheets(); + await buttons.useCompactWorkspaceMenu(); await buttons.hideAndUnloadOpenButtons(); }); }); + +test("plugin tooltips and menu labels follow app language with a panel draft open", async ({ + page, +}, info) => { + test.setTimeout(180_000); + await verifyPluginToolTranslations(page, info); +}); diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts new file mode 100644 index 0000000000..460faeb4ef --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -0,0 +1,279 @@ +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Frame, TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { copyPluginExample } from "../support/helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { + openFileExplorer, + openFileFromExplorer, + expectFileTabOpen, +} from "../support/helpers/file-explorer"; + +// Full Chromium includes the PDF viewer; the default headless shell does not. +test.use({ channel: "chromium" }); + +function samplePdf() { + const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET"; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + ]; + let pdf = "%PDF-1.4\n%\u0000\u00ff\n"; + const offsets = objects.map((object, index) => { + const offset = Buffer.byteLength(pdf); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + return offset; + }); + const xref = Buffer.byteLength(pdf); + pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`; + pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return pdf; +} + +async function openFile(page: Page, name: string) { + await openFileFromExplorer(page, name); + await expectFileTabOpen(page, name); +} + +const WIDE = { width: 1280, height: 900 }; +const COMPACT = { width: 390, height: 844 }; + +async function capture(page: Page, testInfo: TestInfo, name: string) { + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file }); + await testInfo.attach(name, { path: file, contentType: "image/png" }); +} + +/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */ +async function captureWideAndCompact( + page: Page, + testInfo: TestInfo, + name: string, + ready: () => Promise, +) { + await capture(page, testInfo, `${name}-wide`); + // The shell survives the breakpoint, so the previous layout's copy can still be on screen right + // after a resize. Check `ready` only once the shell reports the new layout. + await page.setViewportSize(COMPACT); + await expect(page.getByTestId("compact-shell")).toBeVisible(); + await ready(); + await capture(page, testInfo, `${name}-compact`); + await page.setViewportSize(WIDE); + await expect(page.getByTestId("wide-shell")).toBeVisible(); + await ready(); +} + +/** Turns on vim keybindings without discarding settings the test writes later. */ +async function enableVimKeybindings(page: Page) { + await page.addInitScript(() => { + const key = "@paseo:app-settings"; + const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true })); + }); +} + +async function chooseRichMarkdown(page: Page) { + await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click(); + await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click(); + await page.getByRole("button", { name: "Use", exact: true }).click(); +} + +async function replaceMarkdown(page: Page, text: string) { + await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text); +} + +async function openWithFromTabMenu(page: Page, name: string, option: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open with…" }).hover(); + await page.getByRole("menuitem", { name: option }).click(); +} + +async function selectFileView(page: Page, view: "Preview" | "Source") { + const option = page + .getByTestId("file-preview-mode") + .filter({ visible: true }) + .getByRole("button", { name: view, exact: true }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); +} + +async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") { + const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true }); + await button.click(); + await page.reload(); + if (view === "Source") { + await expectMarkdownEditor(page, "Edited by Rich Markdown"); + } else { + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + } +} + +/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ +async function pdfViewer(page: Page): Promise { + const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/; + const find = () => + page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null; + await expect.poll(() => find() !== null).toBe(true); + return find()!; +} + +async function expectPdfViewerReady(page: Page) { + // A narrow viewer collapses its toolbar to Print and More actions. + const viewer = await pdfViewer(page); + await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled(); +} + +async function expectMarkdownEditor(page: Page, text: string) { + const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }); + await expect(editor).toBeVisible(); + await expect(editor).toContainText(text); + await expect(editor).toBeInViewport({ ratio: 1 }); +} + +test("directory examples mount the PDF frame and write Markdown through the file handle", async ({ + page, +}, testInfo) => { + test.setTimeout(180_000); + const pdf = await copyPluginExample("pdf-panel"); + const markdown = await copyPluginExample("markdown-panel"); + execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund"], + { cwd: markdown.directory, stdio: "pipe" }, + ); + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-examples-", + repo: { + files: [ + { + path: "notes.md", + content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n", + }, + { path: "second.md", content: "# Remembered panel\n" }, + { path: "report.pdf", content: samplePdf() }, + ], + }, + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pdf.directory); + await client.installDirectoryPlugin(markdown.directory); + await enableVimKeybindings(page); + await page.setViewportSize(WIDE); + const gate = await installDaemonWebSocketGate(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openFile(page, "report.pdf"); + await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( + "src", + /^blob:/, + ); + await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute( + "sandbox", + ); + const documentUrl = await page + .getByTitle("Document preview", { exact: true }) + .getAttribute("src"); + expect( + await page.evaluate( + async (url) => (await fetch(url!)).blob().then((blob) => blob.type), + documentUrl, + ), + ).toBe("application/pdf"); + await expectPdfViewerReady(page); + await expect( + (await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }), + ).toHaveValue("1"); + await captureWideAndCompact(page, testInfo, "pdf-example", async () => { + await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport(); + await expectPdfViewerReady(page); + }); + + await openFile(page, "notes.md"); + await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked(); + await expect( + page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("radio")).toHaveCount(2); + await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => { + // The previous layout's picker animates out (the sheet slides down, the dialog fades), so + // both pickers are mounted for a moment after a resize. + await expect(page.getByRole("button", { name: "Use", exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({ + ratio: 1, + }); + }); + await chooseRichMarkdown(page); + await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText( + "Markdown panel example", + ); + gate.holdNextClientRequest("fs.file.write.request"); + await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n"); + await gate.waitForHeldClientRequest(); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); + await capture(page, testInfo, "markdown-example-dirty-wide"); + gate.releaseHeldClientRequest(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) + .toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden(); + await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => { + await expectMarkdownEditor(page, "Edited by Rich Markdown"); + }); + + await rememberMarkdownViewAcrossReload(page, "Preview"); + await rememberMarkdownViewAcrossReload(page, "Source"); + + // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the + // cursor and vim mode in the panel's toolbar. + await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + await selectFileView(page, "Source"); + const source = page.getByTestId("file-source-editor").filter({ visible: true }); + await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown"); + await source.locator(".cm-line").nth(2).click(); + const bar = page.getByTestId("file-panel-bar").filter({ visible: true }); + await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible(); + await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible(); + await capture(page, testInfo, "markdown-example-source-wide"); + await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel"); + await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n"); + await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText( + "# External update", + ); + await replaceMarkdown(page, "# Local edit\n"); + await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n"); + await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible(); + await capture(page, testInfo, "markdown-example-conflict-wide"); + await page.getByRole("button", { name: "Overwrite", exact: true }).click(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) + .toBe("# Local edit\n"); + + await openFile(page, "second.md"); + await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText( + "Remembered panel", + ); + await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); + await page.setViewportSize(COMPACT); + await expectMarkdownEditor(page, "Remembered panel"); + } finally { + await client.removePlugin("pdf-panel"); + await client.removePlugin("markdown-panel"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + await client.close(); + await workspace.cleanup(); + await pdf.cleanup(); + await markdown.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts new file mode 100644 index 0000000000..0fc47d7a16 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts @@ -0,0 +1,171 @@ +import type { TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer"; +import { + BUILTIN_MARKDOWN_OPTION, + MARKDOWN_PANEL_ID, + PLUGIN_MARKDOWN_OPTION, + expectBuiltinMarkdown, + expectPluginMarkdown, + filePanelPicker, + installMarkdownPanelPlugin, + openMarkdown, + storedFilePanelAssociations, +} from "../support/helpers/markdown-file-panel-plugin"; + +async function choosePanel(page: Page, option: string, options: { remember: boolean }) { + await page.getByRole("radio", { name: option, exact: true }).click(); + if (options.remember) { + await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click(); + } + await page.getByRole("button", { name: "Use", exact: true }).click(); + await expect(filePanelPicker(page)).toBeHidden(); +} + +async function openWithFromTabMenu(page: Page, name: string, option: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open with…" }).hover(); + await page.getByRole("menuitem", { name: option }).click(); +} + +async function toggleAlwaysUseFromTabMenu(page: Page, name: string) { + await openWithFromTabMenu(page, name, "Always use for .md"); +} + +/** The built-in panel's toolbar control between its Preview and Source views. */ +function viewControl(page: Page) { + return page.getByTestId("file-preview-mode").filter({ visible: true }); +} + +async function selectFileView(page: Page, view: "Preview" | "Source") { + const option = viewControl(page).getByRole("button", { name: view, exact: true }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); +} + +async function expectNoOpenWith(page: Page, name: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function capture(page: Page, testInfo: TestInfo, name: string) { + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file }); + await testInfo.attach(name, { path: file, contentType: "image/png" }); +} + +test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-builtins-", + repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] }, + }); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openMarkdown(page, "notes.md"); + await expectBuiltinMarkdown(page, "notes heading"); + await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]); + await expect(filePanelPicker(page)).toBeHidden(); + await expectNoOpenWith(page, "notes.md"); + await capture(page, testInfo, "file-panel-builtins-wide"); + + await selectFileView(page, "Source"); + await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); + await selectFileView(page, "Preview"); + await expectBuiltinMarkdown(page, "notes heading"); + + await page.setViewportSize({ width: 390, height: 844 }); + await expectBuiltinMarkdown(page, "notes heading"); + await expect( + viewControl(page).getByRole("button", { name: "Source", exact: true }), + ).toBeInViewport({ ratio: 1 }); + await expect(filePanelPicker(page)).toBeHidden(); + await capture(page, testInfo, "file-panel-builtins-compact"); + } finally { + await workspace.cleanup(); + } +}); + +test("asks once which panel opens Markdown and remembers it when told to", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-picker-", + repo: { + files: ["first", "second", "third", "fourth"].map((name) => ({ + path: `${name}.md`, + content: `# ${name} heading\n`, + })), + }, + }); + const plugin = await installMarkdownPanelPlugin(); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + // First open: the built-in renders under the picker. + await openMarkdown(page, "first.md"); + await expect(filePanelPicker(page)).toBeVisible(); + await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked(); + await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked(); + await expect(page.getByRole("radio")).toHaveCount(2); + await expectBuiltinMarkdown(page, "first heading"); + await capture(page, testInfo, "file-panel-picker-wide"); + + // Don't ask me again: the plugin renders and later opens use it silently. + await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true }); + await expectPluginMarkdown(page, "first.md"); + expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID }); + await openMarkdown(page, "second.md"); + await expectPluginMarkdown(page, "second.md"); + await expect(filePanelPicker(page)).toBeHidden(); + + // Forgetting from the tab menu keeps the tab on the plugin. + await toggleAlwaysUseFromTabMenu(page, "second.md"); + await expect.poll(() => storedFilePanelAssociations(page)).toEqual({}); + await expectPluginMarkdown(page, "second.md"); + + // An unremembered choice applies to this tab only; the next open asks again. + await openMarkdown(page, "third.md"); + await expect(filePanelPicker(page)).toBeVisible(); + await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false }); + await expectPluginMarkdown(page, "third.md"); + expect(await storedFilePanelAssociations(page)).toEqual({}); + + // "Open with…" switches the open tab back to the built-in in place, with its own views. + await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION); + await expectBuiltinMarkdown(page, "third heading"); + await expectFileTabOpen(page, "third.md"); + await selectFileView(page, "Source"); + await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); + await selectFileView(page, "Preview"); + await expectBuiltinMarkdown(page, "third heading"); + + // Dismissing keeps the built-in; reopening the tab after a reload asks again. + await openMarkdown(page, "fourth.md"); + await expect(filePanelPicker(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(filePanelPicker(page)).toBeHidden(); + await expectBuiltinMarkdown(page, "fourth heading"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({ + ratio: 1, + }); + await capture(page, testInfo, "file-panel-picker-compact"); + } finally { + await plugin.uninstall(); + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts new file mode 100644 index 0000000000..7f9350ed05 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts @@ -0,0 +1,150 @@ +import type { TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { openSettings } from "../support/helpers/app"; +import { openFileExplorer } from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + BUILTIN_MARKDOWN_OPTION, + MARKDOWN_PANEL_ID, + PLUGIN_MARKDOWN_OPTION, + expectPluginMarkdown, + filePanelPicker, + installMarkdownPanelPlugin, + openMarkdown, + storedFilePanelAssociations, +} from "../support/helpers/markdown-file-panel-plugin"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { openSettingsSection } from "../support/helpers/settings"; + +const ASK_EVERY_TIME = "Ask every time"; +const SOURCE_OPTION = "Source — Paseo"; + +function markdownRow(page: Page) { + return page.getByTestId("file-panel-settings-row-ext:md"); +} + +function markdownDropdown(page: Page, value: string) { + return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true }); +} + +function askAgain(page: Page) { + return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true }); +} + +async function openFilesSettings(page: Page) { + await openSettings(page); + await openSettingsSection(page, "files"); + await expect(page.getByTestId("file-panel-settings")).toBeVisible(); +} + +async function capture(page: Page, testInfo: TestInfo, name: string) { + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file }); + await testInfo.attach(name, { path: file, contentType: "image/png" }); +} + +async function expectMenuOpen(page: Page) { + await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1"); +} + +test("Settings > Files chooses which panel opens .md and asks again on request", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-settings-", + repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] }, + }); + const plugin = await installMarkdownPanelPlugin(); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await openFilesSettings(page); + + // Nothing remembered: the row asks every time and has nothing to forget. + await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 }); + await expect(askAgain(page)).toBeDisabled(); + await capture(page, testInfo, "file-panel-settings-wide"); + + await markdownDropdown(page, ASK_EVERY_TIME).click(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0); + await expectMenuOpen(page); + await capture(page, testInfo, "file-panel-settings-wide-menu"); + await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click(); + await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible(); + await expect + .poll(() => storedFilePanelAssociations(page)) + .toEqual({ + "ext:md": MARKDOWN_PANEL_ID, + }); + await capture(page, testInfo, "file-panel-settings-wide-chosen"); + + // The next open uses the plugin without asking. + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openMarkdown(page, "notes.md"); + await expectPluginMarkdown(page, "notes.md"); + await expect(filePanelPicker(page)).toBeHidden(); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/settings/files"); + await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 }); + await expect(askAgain(page)).toBeInViewport({ ratio: 1 }); + await capture(page, testInfo, "file-panel-settings-compact"); + await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); + await expectMenuOpen(page); + await capture(page, testInfo, "file-panel-settings-compact-menu"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden(); + + // Ask again forgets the choice; the next open asks. + await askAgain(page).click(); + await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible(); + await expect.poll(() => storedFilePanelAssociations(page)).toEqual({}); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 }); + } finally { + await plugin.uninstall(); + await workspace.cleanup(); + } +}); + +test("Settings > Files explains where file panels come from when no plugin adds one", async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/settings/files"); + const empty = page.getByTestId("file-panel-settings-empty"); + await expect(empty).toHaveText("File panels appear here when a plugin adds one", { + timeout: 30_000, + }); + await capture(page, testInfo, "file-panel-settings-empty-wide"); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(empty).toBeVisible(); + await capture(page, testInfo, "file-panel-settings-empty-compact"); +}); + +test("Settings > Files offers Source in place of a panel no host has, and forgets it", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }), + ); + }); + await page.goto("/settings/files"); + const row = page.getByTestId("file-panel-settings-row-ext:pdf"); + const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" }); + await expect(dropdown).toBeEnabled({ timeout: 30_000 }); + await dropdown.click(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden(); + await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click(); + await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible(); +}); diff --git a/packages/app/e2e/browser/plugin-file-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts new file mode 100644 index 0000000000..8567147cda --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts @@ -0,0 +1,236 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { pluginRequirements } from "../support/helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + openFileExplorer, + openFileFromExplorer, + expectFileTabOpen, +} from "../support/helpers/file-explorer"; + +const PLUGIN_ID = "file-panel-e2e"; +const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<>endobj\n%%EOF", "binary"); +const SOURCE = `import React, { useEffect, useState } from "react"; +import { Text, View, Pressable } from "react-native"; +import { + PluginFileConflictError, + useFileBytes, + useFileUrl, + usePaseo, + useWorkspace, +} from "@getpaseo/plugin/client"; +import { useQueryClient } from "@tanstack/react-query"; +import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui"; +function Pdf({ file, workspaceId }) { + usePaseo(); + useQueryClient(); + const workspace = useWorkspace(workspaceId, value => ({ id: value.id })); + const bytes = useFileBytes(file); + const url = useFileUrl(file); + const [urlBytes, setUrlBytes] = useState(-1); + const href = url.status === "ready" ? url.url : null; + useEffect(() => { + if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength)); + }, [href]); + if (bytes.status !== "ready" || !href) return ; + return + PDF plugin: {file.name} + Path {file.path} + Bytes {bytes.bytes.length}; URL bytes {urlBytes} + Workspace {workspace?.id} + + ; +} +// Reads once and writes on demand, so a change on disk since that read rejects the write. +function Markdown({ file, tab }) { + const [text, setText] = useState(null); + const [status, setStatus] = useState(""); + useEffect(() => { file.readText().then(setText); }, [file]); + const save = (next) => { + tab.setModified(true); + file.writeText(next).then( + () => { tab.setModified(false); setText(next); }, + (error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)), + ); + }; + if (text === null) return ; + return + Plugin Markdown: {text} + save("# Saved by plugin\\n")}> + Edit Markdown through plugin + + {status} + ; +} +export default function contribute(client) { + client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText", + files: { extensions: ["pdf", "txt"] }, Component: Pdf }); + client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText", + files: { extensions: ["md"] }, Component: Markdown }); + return () => {}; +}`; + +async function openWorkspaceFile(page: Page, name: string) { + await openFileFromExplorer(page, name); + await expectFileTabOpen(page, name); +} +async function associateMarkdownWithPlugin(page: Page) { + await page.evaluate(() => { + const key = "@paseo:app-settings"; + const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); + settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" }; + localStorage.setItem(key, JSON.stringify(settings)); + }); + await page.reload(); +} +async function editMarkdownThroughPlugin(page: Page) { + await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click(); +} +/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */ +async function openBuiltinMarkdownSource(page: Page, name: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open with…" }).hover(); + await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click(); + await page + .getByTestId("file-preview-mode") + .filter({ visible: true }) + .getByRole("button", { name: "Source", exact: true }) + .click(); +} + +test("directory plugins render PDF bytes and write Markdown through the file handle", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-")); + const workspace = await seedWorkspace({ + repoPrefix: "file-panels-", + repo: { + files: [ + { path: "notes.md", content: "# Built-in Markdown\n" }, + { path: "bytes.txt", content: "Bytes panel text document" }, + ], + }, + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await client.getDaemonConfig(); + await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), SOURCE); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await page.setViewportSize({ width: 1280, height: 900 }); + const gate = await installDaemonWebSocketGate(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openWorkspaceFile(page, "report.pdf"); + await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible(); + await expect( + page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }), + ).toBeVisible(); + await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( + "src", + /^blob:/, + ); + await expect( + page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }), + ).toBeVisible(); + const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png"); + await page.screenshot({ path: pdfScreenshot }); + await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" }); + + // A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin. + await openWorkspaceFile(page, "bytes.txt"); + await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden(); + await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible(); + await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible(); + await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( + "sandbox", + "", + ); + await expect( + page + .frameLocator('[title="Document preview"]') + .getByText("Bytes panel text document", { exact: true }), + ).toBeVisible(); + + await openWorkspaceFile(page, "notes.md"); + await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); + await expect( + page.getByRole("heading", { name: "Built-in Markdown", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0); + await associateMarkdownWithPlugin(page); + await expect( + page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }), + ).toBeVisible(); + gate.holdNextClientRequest("fs.file.write.request"); + await editMarkdownThroughPlugin(page); + await gate.waitForHeldClientRequest(); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); + gate.releaseHeldClientRequest(); + await expect( + page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }), + ).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) + .toBe("# Saved by plugin\n"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden(); + + // The disk changed since the plugin's last read or write: the write is rejected, not applied. + await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n"); + await editMarkdownThroughPlugin(page); + await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible(); + expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe( + "# Changed outside\n", + ); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); + let closePrompt = ""; + page.once("dialog", async (dialog) => { + closePrompt = dialog.message(); + await dialog.dismiss(); + }); + await page + .getByTestId("workspace-tab-file_notes.md") + .filter({ visible: true }) + .first() + .click({ button: "right" }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect.poll(() => closePrompt).toContain("discard"); + + await openBuiltinMarkdownSource(page, "notes.md"); + await expect( + page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"), + ).toHaveText("# Changed outside"); + const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png"); + await page.screenshot({ path: markdownScreenshot }); + await testInfo.attach("plugin-markdown-source-web", { + path: markdownScreenshot, + contentType: "image/png", + }); + } finally { + await client.removePlugin(PLUGIN_ID); + await client.patchDaemonConfig({ + pluginsEnabled: previousConfig.config.pluginsEnabled ?? false, + }); + await client.close(); + await workspace.cleanup(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 84d786033a..b63728ff73 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -1,3 +1,14 @@ +import { + withRetainedPlugin, + editRetainedDraft, + expectRetainedDraft, + expectStoredPluginTitle, + hideRetainedPreferences, + showRetainedPreferences, + expectChangedPlugin, + expectRemovedPlugin, +} from "../support/helpers/plugin-reconnect"; +import { verifyPluginTranslations } from "../support/helpers/plugin-translations"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -5,15 +16,11 @@ import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; -import { - expectMobileAgentSidebarVisible, - openMobileAgentSidebar, -} from "../support/helpers/sidebar"; const PLUGIN_ID = "plugin-host-ui-e2e"; const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; +import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -30,6 +37,9 @@ function ModalBody({ onSaved }) { return Plugin modal contexts ready + {"## Plugin report"} + {"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"} + {""} Save issue @@ -91,20 +101,24 @@ async function useCompactLayout(page: Page): Promise { await page.setViewportSize({ width: 390, height: 844 }); } -async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise { - await openMobileAgentSidebar(page); - await expectMobileAgentSidebarVisible(page); - await page.getByRole("button", { name: "Host UI", exact: true }).click(); -} - async function openPluginModal(page: Page): Promise { await page.getByRole("button", { name: "Open plugin modal", exact: true }).click(); } +async function expectPluginMarkdown(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible(); + await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible(); + await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute( + "href", + "https://paseo.sh/docs/plugins", + ); +} + async function expectCenteredPluginDialog(page: Page): Promise { const dialog = page.getByRole("dialog"); await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible(); await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible(); + await expectPluginMarkdown(page); } async function closeCenteredPluginDialog(page: Page): Promise { @@ -116,6 +130,7 @@ async function closeCenteredPluginDialog(page: Page): Promise { async function expectCompactPluginSheet(page: Page): Promise { await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible(); await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible(); + await expectPluginMarkdown(page); } async function savePluginIssue(page: Page): Promise { @@ -158,7 +173,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( await test.step("compact layouts preserve host contexts inside a sheet", async () => { await useCompactLayout(page); - await reopenHostUiPluginFromCompactSidebar(page); await openPluginModal(page); await expectCompactPluginSheet(page); await savePluginIssue(page); @@ -177,3 +191,58 @@ async function playPluginAudio(page: Page, button: string, result: string) { await page.getByRole("button", { name: button, exact: true }).click(); await expect(page.getByText(result, { exact: true })).toBeVisible(); } + +test("plugin content and host labels follow app language without losing an open draft", async ({ + page, +}, info) => { + test.setTimeout(180_000); + await verifyPluginTranslations(page, info); +}); + +test("retains plugin state offline and reconciles settings, changed code and removal", async ({ + page, +}) => { + test.setTimeout(180_000); + await withRetainedPlugin(page, async (probe) => { + await probe.open(); + await editRetainedDraft(page, "Connected draft", 0); + + await test.step("local plugin UI remains mounted and usable while disconnected", async () => { + await probe.disconnect(); + await expectRetainedDraft(page, "Connected draft", 1); + await editRetainedDraft(page, "Offline draft", 1); + await probe.changeStoredTitle("Saved by another client"); + await expectStoredPluginTitle(page, "Agent monitor"); + await probe.reconnect(); + await expectStoredPluginTitle(page, "Saved by another client"); + await expectRetainedDraft(page, "Offline draft", 2); + }); + + await test.step("inactive settings refresh without replacing the installation", async () => { + await hideRetainedPreferences(page); + await probe.disconnect(); + await probe.changeStoredTitle("Saved while preferences hidden"); + await probe.reconnect(); + await showRetainedPreferences(page); + await expectStoredPluginTitle(page, "Saved while preferences hidden"); + await expectRetainedDraft(page, "Offline draft", 2); + }); + + await test.step("changed code replaces the retained installation after reconnect", async () => { + await probe.disconnect(); + await probe.changeCode(); + await expectRetainedDraft(page, "Offline draft", 2); + await probe.reconnect(); + await expectChangedPlugin(page); + await editRetainedDraft(page, "New code draft", 0); + }); + + await test.step("removal is reconciled after reconnect", async () => { + await probe.disconnect(); + await probe.remove(); + await expectRetainedDraft(page, "New code draft", 1); + await probe.reconnect(); + await expectRemovedPlugin(page); + }); + }); +}); diff --git a/packages/app/e2e/browser/plugin-icons.spec.ts b/packages/app/e2e/browser/plugin-icons.spec.ts new file mode 100644 index 0000000000..0a66cd797d --- /dev/null +++ b/packages/app/e2e/browser/plugin-icons.spec.ts @@ -0,0 +1,138 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Locator, TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { gotoAppShell } from "../support/helpers/app"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { pluginRequirements } from "../support/helpers/plugin-fixture"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + switchWorkspaceViaSidebar, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; + +const PLUGIN_ID = "plugin-icons-e2e"; +const SVG_PATH = "M4 12l5 5L20 6"; + +const pluginClientSource = `import React from "react"; +import { Text, View } from "react-native"; + +const checkIcon = ''; + +function BadgeIcon({ size, color }) { + return ; +} + +function Panel() { + return Plugin icon panel; +} + +const panels = [ + { id: "lucide", title: "Lucide icon panel", icon: "Scan" }, + { id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } }, + { id: "component", title: "Component icon panel", icon: BadgeIcon }, +]; + +export default function contribute(client) { + for (const panel of panels) { + client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel }); + client.addCommandCenterItem({ + id: panel.id, + title: "Open " + panel.title, + icon: panel.icon, + context: "workspace", + onSelect({ openPanel }) { openPanel(panel.id); }, + }); + } + return () => {}; +}`; + +function pluginTab(page: Page, panelId: string): Locator { + const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`; + return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true }); +} + +async function runCommand(page: Page, title: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(title); + await panel.getByRole("button", { name: title, exact: true }).click(); + await expect(panel).not.toBeVisible(); +} + +async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshot = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: screenshot }); + await testInfo.attach(name, { path: screenshot, contentType: "image/png" }); +} + +test.describe("plugin icons", () => { + test.describe.configure({ timeout: 180_000 }); + + test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource); + + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await page.setViewportSize({ width: 1280, height: 800 }); + await gotoAppShell(page); + const target = { serverId: getServerId(), workspaceId: workspace.workspaceId }; + await waitForWorkspaceInSidebar(page, target); + await switchWorkspaceViaSidebar({ page, ...target }); + + await test.step("Command Center rows draw each icon form", async () => { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("icon panel"); + for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) { + await expect( + panel.getByRole("button", { name: `Open ${title}`, exact: true }), + ).toBeVisible(); + } + await expect( + panel + .getByRole("button", { name: "Open SVG icon panel", exact: true }) + .locator(`path[d="${SVG_PATH}"]`), + ).toBeVisible(); + await expect( + panel + .getByRole("button", { name: "Open Component icon panel", exact: true }) + .getByTestId("plugin-component-icon"), + ).toBeVisible(); + await capture(page, testInfo, "command-center-icons"); + await page.keyboard.press("Escape"); + }); + + await test.step("workspace panel tabs draw each icon form", async () => { + await runCommand(page, "Open Lucide icon panel"); + await runCommand(page, "Open SVG icon panel"); + await runCommand(page, "Open Component icon panel"); + await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible(); + await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible(); + await expect( + pluginTab(page, "component").getByTestId("plugin-component-icon"), + ).toBeVisible(); + await capture(page, testInfo, "workspace-tab-icons"); + }); + } finally { + try { + await client.removePlugin(PLUGIN_ID); + } finally { + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await workspace.cleanup(); + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + } + }); +}); diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts index bdd822b9c8..33be8a5c40 100644 --- a/packages/app/e2e/browser/plugin-modal-body.spec.ts +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -2,10 +2,6 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture"; import { expect, test as base, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; -import { - openMobileAgentSidebar, - expectMobileAgentSidebarVisible, -} from "../support/helpers/sidebar"; async function openExample(page: Page, example: string) { await page.getByRole("button", { name: `Open ${example}`, exact: true }).click(); @@ -122,9 +118,7 @@ async function openWideExamples(page: Page) { async function openCompactExamples(page: Page) { await page.setViewportSize({ width: 390, height: 844 }); - await openMobileAgentSidebar(page); - await expectMobileAgentSidebarVisible(page); - await page.getByRole("button", { name: "Modal examples", exact: true }).click(); + await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible(); } async function expectDeniedCopyFeedback(page: Page) { diff --git a/packages/app/e2e/browser/plugin-subscription-examples.spec.ts b/packages/app/e2e/browser/plugin-subscription-examples.spec.ts new file mode 100644 index 0000000000..8cb25576bc --- /dev/null +++ b/packages/app/e2e/browser/plugin-subscription-examples.spec.ts @@ -0,0 +1,79 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { copyPluginExample } from "../support/helpers/plugin-fixture"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + switchWorkspaceViaSidebar, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; + +async function runCommand(page: Page, title: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(title); + await panel.getByRole("button", { name: title, exact: true }).click(); + await expect(panel).not.toBeVisible(); +} + +/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */ +async function withExample( + page: Page, + example: { name: string; id: string }, + exercise: (workspace: SeededWorkspace) => Promise, +): Promise { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const copy = await copyPluginExample(example.name); + const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` }); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(copy.directory); + await page.setViewportSize({ width: 1280, height: 800 }); + await gotoAppShell(page); + const target = { serverId: getServerId(), workspaceId: workspace.workspaceId }; + await waitForWorkspaceInSidebar(page, target); + await switchWorkspaceViaSidebar({ page, ...target }); + await exercise(workspace); + } finally { + try { + await client.removePlugin(example.id); + } finally { + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await workspace.cleanup(); + await client.close(); + await copy.cleanup(); + } + } +} + +test.describe("plugin subscriptions", () => { + test.describe.configure({ timeout: 180_000 }); + + test("a panel follows events the daemon pushes as files change", async ({ page }) => { + await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => { + await runCommand(page, "Open live changes"); + await expect(page.getByText("No changes", { exact: true })).toBeVisible(); + + await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n"); + await expect(page.getByText("1 file changed", { exact: true })).toBeVisible(); + await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible(); + }); + }); + + test("a panel plays streamed bytes and sends bytes back", async ({ page }) => { + await withExample(page, { name: "media-stream", id: "media-stream" }, async () => { + await runCommand(page, "Open media stream"); + await expect(page.getByText("live", { exact: true })).toBeVisible(); + await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible(); + + await page.getByRole("button", { name: "Send this frame back", exact: true }).click(); + await expect( + page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/), + ).toBeVisible(); + }); + }); +}); diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts index a344611a4f..d75653298e 100644 --- a/packages/app/e2e/browser/plugin-timeline.spec.ts +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -1,10 +1,14 @@ import { test } from "../support/fixtures"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { withTimelinePlugin, + viewInlineThinkingExample, requestPluginTimeline, interactWithStreamingCard, expectWholeCompletedCard, expectBothConsecutiveTools, + trackPluginChatRecovery, + reconnectPluginChat, } from "../support/helpers/plugin-timeline"; for (const width of [1100, 390]) { @@ -12,13 +16,24 @@ for (const width of [1100, 390]) { page, }, info) => { await page.setViewportSize({ width, height: 900 }); + const gate = await installDaemonWebSocketGate(page); await withTimelinePlugin(page, info, "assistant", async (agent) => { await requestPluginTimeline(agent); await interactWithStreamingCard(page); await agent.client.waitForFinish(agent.agentId, 30_000); await expectWholeCompletedCard(page); // Local state is checked during growth; the viewport remounts on the history handoff. + const recovery = trackPluginChatRecovery(page); + gate.holdTimelineResponses(agent.agentId); await page.reload({ waitUntil: "domcontentloaded" }); + await gate.waitForHeldTimelineResponse(); + await recovery.expectCatalogWithoutRetransmission(); + gate.releaseHeldTimelineResponses(); + await expectWholeCompletedCard(page); + await recovery.expectRecoveredWithoutRetransmission(); + recovery.reset(); + await reconnectPluginChat(page, gate); + await recovery.expectRecoveredWithoutRetransmission(); await expectWholeCompletedCard(page); }); }); @@ -33,3 +48,9 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i await expectBothConsecutiveTools(page); }); }); + +test("inline-thinking example renders a streamed thought through host UI", async ({ + page, +}, info) => { + await viewInlineThinkingExample(page, info); +}); diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 8895f85acf..36f92429c7 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -36,7 +36,7 @@ function pluginClientSource(input: { }): string { return `import React, { useRef } from "react"; import { Pressable, Text, View } from "react-native"; -import { Icon } from "@getpaseo/plugin/client/react-native"; +import { Icon } from "@getpaseo/plugin/client/ui"; import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client"; import { ExternalLink } from "@getpaseo/plugin/client/ui"; import { recordComposerOpen } from "./shared/rpc"; diff --git a/packages/app/e2e/browser/projects-settings.spec.ts b/packages/app/e2e/browser/projects-settings.spec.ts index 3845e017a6..0732ec6732 100644 --- a/packages/app/e2e/browser/projects-settings.spec.ts +++ b/packages/app/e2e/browser/projects-settings.spec.ts @@ -204,26 +204,36 @@ test.describe("Projects settings", () => { }) => { const repo = await createTempGitRepo("projects-settings-empty-"); const client = await connectSeedClient(); - let projectId: string | null = null; + let projectViewKey: string | null = null; try { await gotoAppShell(page); - projectId = await addProjectFromSidebar(page, repo.path); - await openProjectSettingsFromSidebar(page, projectId); + projectViewKey = await addProjectFromSidebar(page, repo.path); + await openProjectSettingsFromSidebar(page, projectViewKey); await expectProjectSettingsFormVisible(page); await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible(); } finally { - if (projectId) { - await client.removeProject(projectId).catch(() => undefined); + if (projectViewKey) { + const project = (await client.listProjects()).projects.find( + (entry) => entry.projectKey === projectViewKey, + ); + expect(project).toBeDefined(); + await client.removeProject(project!.projectId); + expect( + (await client.listProjects()).projects.map((entry) => entry.projectKey), + ).not.toContain(projectViewKey); } await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); } }); - test("user edits worktree setup from the projects page", async ({ page, editableProject }) => { + test("edits worktree setup, navigates within the host, and removes a script", async ({ + page, + editableProject, + }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await expectNoUncommittedSetupWarning(page); @@ -236,15 +246,22 @@ test.describe("Projects settings", () => { await returnToProjectsList(page); await openProjectSettings(page, editableProject.name); await expectNoUncommittedSetupWarning(page); - }); - test("project navigation stays inside the selected host", async ({ page, editableProject }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - await expectProjectHostContextHidden(page); - await returnToProjectsList(page); - await openProjectSettings(page, editableProject.name); - await expectProjectSettingsHistoryRoundTrip(page, editableProject.name); + await test.step("navigation stays inside the selected host", async () => { + await expectProjectHostContextHidden(page); + await returnToProjectsList(page); + await openProjectSettings(page, editableProject.name); + await expectProjectSettingsHistoryRoundTrip(page, editableProject.name); + }); + + await test.step("removing a script leaves the committed setup unchanged", async () => { + await expectScriptRowCount(page, 1); + await removeProjectScript(page, "dev"); + await expectScriptRowCount(page, 0); + await expectEmptyScriptList(page); + await clickSaveProjectSettings(page); + await expectNoUncommittedSetupWarning(page); + }); }); test("user edits worktree setup on a non-GitHub remote project", async ({ @@ -259,18 +276,7 @@ test.describe("Projects settings", () => { await expectProjectConfigSaved(gitlabRemoteProject); }); - test("user renames a project from the edit sheet", async ({ page, editableProject }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - await openProjectEditSheet(page); - await fillProjectName(page, "Renamed project"); - await saveProjectEdits(page); - - await expectProjectEditSaved(page); - await expectProjectTitle(page, "Renamed project"); - }); - - test("reopening the edit sheet seeds from the saved project", async ({ + test("renames a project, reopens the saved edit sheet, and adds an icon", async ({ page, editableProject, }) => { @@ -279,26 +285,26 @@ test.describe("Projects settings", () => { await openProjectEditSheet(page); await fillProjectName(page, "Renamed project"); await saveProjectEdits(page); + await expectProjectEditSaved(page); + await expectProjectTitle(page, "Renamed project"); - await openProjectEditSheet(page); - - await expectProjectEditName(page, "Renamed project"); - await expectProjectEditsSaveDisabled(page); - }); - - test("user picks a custom project icon from a file", async ({ page, editableProject }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - await openProjectEditSheet(page); - await chooseProjectIconImage(page, { - name: "logo.png", - mimeType: "image/png", - buffer: PNG_1X1, + await test.step("reopening seeds the saved name with no unsaved changes", async () => { + await openProjectEditSheet(page); + await expectProjectEditName(page, "Renamed project"); + await expectProjectEditsSaveDisabled(page); }); - await saveProjectEdits(page); - await expectProjectEditSaved(page); + await test.step("an icon can be saved without another name change", async () => { + await chooseProjectIconImage(page, { + name: "logo.png", + mimeType: "image/png", + buffer: PNG_1X1, + }); + await saveProjectEdits(page); + await expectProjectEditSaved(page); + await expectProjectTitle(page, "Renamed project"); + }); }); test("user sets a project name and icon in one save", async ({ page, editableProject }) => { @@ -459,31 +465,4 @@ test.describe("Projects settings — error UX", () => { await expectNoEditableTarget(page); }); - - test("project detail does not render a second host selector", async ({ - page, - editableProject, - }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - - await expectProjectHostContextHidden(page); - }); - - test("script removal via kebab menu removes the row from the form", async ({ - page, - editableProject, - }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - - await expectScriptRowCount(page, 1); - - await removeProjectScript(page, "dev"); - - await expectScriptRowCount(page, 0); - await expectEmptyScriptList(page); - await clickSaveProjectSettings(page); - await expectNoUncommittedSetupWarning(page); - }); }); diff --git a/packages/app/e2e/browser/provider-catalog-icons.spec.ts b/packages/app/e2e/browser/provider-catalog-icons.spec.ts new file mode 100644 index 0000000000..818d88ab84 --- /dev/null +++ b/packages/app/e2e/browser/provider-catalog-icons.spec.ts @@ -0,0 +1,51 @@ +import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons"; +import { expect, test, type Page } from "../support/fixtures"; +import type { TestInfo } from "@playwright/test"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { + findAcpCatalogProvider, + openAddProviderArea, + openSettingsHost, + openSettingsHostSection, +} from "../support/helpers/settings"; + +async function openProviderCatalog(page: Page) { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await openAddProviderArea(page); +} + +async function expectProviderArtwork( + page: Page, + testInfo: TestInfo, + id: "devin" | "hermes" | "kiro", + name: string, +) { + await findAcpCatalogProvider(page, name); + // Catalog tiles have no semantic role; scope artwork to the named provider's tile. + const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first(); + await expect(icon).toBeVisible(); + // Provider artwork is 24px; the generic PackagePlus fallback is 20px. + await expect.soft(icon).toHaveAttribute("width", "24"); + const expectedPaths = await page.evaluate((xml) => { + const document = new DOMParser().parseFromString(xml, "image/svg+xml"); + return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!); + }, ACP_PROVIDER_ICON_SVGS[id]); + await expect(icon.locator("path")).toHaveCount(expectedPaths.length); + for (const [index, path] of expectedPaths.entries()) { + await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path); + } + await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) }); +} + +test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({ + page, +}, testInfo) => { + await openProviderCatalog(page); + await expectProviderArtwork(page, testInfo, "devin", "Devin CLI"); + await expectProviderArtwork(page, testInfo, "hermes", "Hermes"); + await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI"); +}); diff --git a/packages/app/e2e/browser/provider-settings-refresh.spec.ts b/packages/app/e2e/browser/provider-settings-refresh.spec.ts index efc4fc7f88..6c9739e894 100644 --- a/packages/app/e2e/browser/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/browser/provider-settings-refresh.spec.ts @@ -1,5 +1,7 @@ +import path from "node:path"; import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -116,28 +118,12 @@ async function exerciseProviderSettingsStack(page: Page) { await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expectProviderSettingsVisible(page); + await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({ + timeout: 30_000, + }); } test.describe("provider settings overlay stack", () => { - test("provider model update age says just now", async ({ page }) => { - const session = await seedMockAgentWorkspace({ - repoPrefix: "provider-relative-time-", - title: "Provider relative time", - }); - try { - await page.clock.install({ time: Date.now() }); - await page.setViewportSize(MOBILE_VIEWPORT); - await openAgentRoute(page, session); - await expectComposerVisible(page); - await openProviderSettingsFromModelSelector(page); - await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({ - timeout: 30_000, - }); - } finally { - await session.cleanup(); - } - }); - test("provider settings covers the desktop model selector without closing it", async ({ page, }) => { @@ -230,3 +216,66 @@ test.describe("provider settings overlay stack", () => { } }); }); + +async function openTimedOutProvider(page: Page, workspaceId: string, width: number) { + await gotoWorkspace(page, workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await page.setViewportSize({ width, height: 900 }); + await page.getByRole("button", { name: /Select model/ }).click(); + if (width < 600) { + await page.getByTestId("agent-controls-model").click(); + } else { + await page.getByRole("button", { name: "Back", exact: true }).click(); + } + await page.getByTestId("model-provider-picker-timeout").click(); + await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible(); +} + +async function expectErrorHasSideGutters(page: Page) { + const error = page.getByText(/Timed out refreshing Pi timeout/); + const bounds = await error.evaluate((element) => { + const text = element.getBoundingClientRect(); + const state = element.parentElement!.getBoundingClientRect(); + return { left: text.left - state.left, right: state.right - text.right }; + }); + expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0); + expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); +} + +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + catalogRefreshTimeoutMs: 2000, + providers: { + "picker-timeout": { + extends: "pi", + label: "Pi timeout", + enabled: true, + command: [ + process.execPath, + path.resolve("e2e/fixtures/fake-pi-rpc.mjs"), + "--stall-model-discovery", + ], + }, + }, + }, + }, +}); + +test.describe("model discovery timeout", () => { + for (const width of [1200, 390]) { + test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({ + page, + withWorkspace, + }, testInfo) => { + await page.setViewportSize({ width: 1200, height: 900 }); + const workspace = await withWorkspace({ prefix: "model-error-spacing-" }); + await openTimedOutProvider(page, workspace.workspaceId, width); + await page.screenshot({ path: testInfo.outputPath("model-error.png") }); + await expectErrorHasSideGutters(page); + }); + } +}); diff --git a/packages/app/e2e/browser/provider-subagents.real.spec.ts b/packages/app/e2e/browser/provider-subagents.real.spec.ts index 08d8a1afe1..b24ca9ba4e 100644 --- a/packages/app/e2e/browser/provider-subagents.real.spec.ts +++ b/packages/app/e2e/browser/provider-subagents.real.spec.ts @@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => { panel.getByText("Start chatting with this agent...", { exact: true }), ).toHaveCount(0); - await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click(); + // A provider subagent has no composer; its callout is the way back to the parent. + await page.getByTestId("parent-agent-callout-open").click(); await expect( page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), ).toBeVisible({ timeout: 60_000 }); diff --git a/packages/app/e2e/browser/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts index b444a8cddf..35002ac2dd 100644 --- a/packages/app/e2e/browser/question-prompt-pagination.spec.ts +++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts @@ -1,6 +1,6 @@ import { fork } from "node:child_process"; import path from "node:path"; -import { expect } from "@playwright/test"; +import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test"; import { z } from "zod"; import { test } from "../support/fixtures"; import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream"; @@ -34,8 +34,214 @@ const ROLLOUT_QUESTION = "Which rollout should we use?"; const SUCCESS_QUESTION = "What success criteria should we use?"; const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?"; const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?"; +const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?"; + +function selectedText(page: Page) { + return page.evaluate(() => window.getSelection()?.toString().trim()); +} + +const CHECKS_QUESTION = "Which checks should run?"; +const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails."; +const LINT_DESCRIPTION = "Rules: https://example.com/lint"; + +async function openLinkedQuestions(page: Page, context: BrowserContext) { + await context.route("https://example.com/**", (route) => + route.fulfill({ status: 200, body: "ok" }), + ); + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-option-links-", + title: "Question option links e2e", + initialPrompt: "Emit synthetic questions with links.", + }); + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + return { session, card: page.getByTestId("question-form-card").first() }; +} + +async function dragAcross(page: Page, target: Locator) { + const bounds = await target.boundingBox(); + if (!bounds) throw new Error("Text is not visible"); + const y = bounds.y + bounds.height / 2; + await page.mouse.move(bounds.x + 1, y); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 }); + await page.mouse.up(); +} + +async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) { + const popup = context.waitForEvent("page"); + await link.click(); + await expect.poll(async () => (await popup).url()).toBe(url); +} + +async function expectChecked(option: Locator, checked: boolean) { + await expect(option).toHaveAttribute("aria-checked", String(checked)); +} + +test.describe("Question option text", () => { + test("links in an option label or description open without answering", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + const done = card.getByRole("radio", { name: "Done" }); + const notYet = card.getByRole("radio", { name: "Not yet" }); + + await clickLinkAndExpectPage( + context, + notYet.getByRole("link", { name: "status" }), + "https://example.com/status", + ); + await clickLinkAndExpectPage( + context, + done.getByRole("link", { name: "https://example.com/logs" }), + "https://example.com/logs", + ); + + await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION }); + await expectChecked(done, false); + await expectChecked(notYet, false); + } finally { + await session.cleanup(); + } + }); + + test("selecting description text keeps the question open", async ({ page, context }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + const done = card.getByRole("radio", { name: "Done" }); + const description = done.getByTestId("question-form-option-description"); + + await dragAcross(page, description); + await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION); + await description.getByText("Logs are at", { exact: false }).dblclick({ + position: { x: 4, y: 4 }, + }); + await expect.poll(() => selectedText(page)).toBe("Logs"); + await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION }); + await expectChecked(done, true); + await continueToNextQuestion(page); + await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); + await openQuestion(page, { index: 1, total: 2 }); + await expectChecked(done, true); + } finally { + await session.cleanup(); + } + }); + + test("double-clicking a single-select label selects once without advancing", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick(); + await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION }); + await expectChecked(card.getByRole("radio", { name: "Done" }), true); + await continueToNextQuestion(page); + + await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); + await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false); + await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false); + await openQuestion(page, { index: 1, total: 2 }); + await expectChecked(card.getByRole("radio", { name: "Done" }), true); + } finally { + await session.cleanup(); + } + }); + + test("multi-select descriptions support links and text selection", async ({ page, context }) => { + test.setTimeout(180_000); + const { session, card } = await openLinkedQuestions(page, context); + try { + await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click(); + await continueToNextQuestion(page); + await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); + + const lint = card.getByRole("checkbox", { name: "Lint" }); + const typecheck = card.getByRole("checkbox", { name: "Typecheck" }); + const description = lint.getByTestId("question-form-option-description"); + + await clickLinkAndExpectPage( + context, + description.getByRole("link", { name: "https://example.com/lint" }), + "https://example.com/lint", + ); + await expectChecked(lint, false); + + await dragAcross(page, description); + await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION); + await description.getByText("Rules:", { exact: false }).dblclick({ + position: { x: 4, y: 4 }, + }); + await expect.poll(() => selectedText(page)).toBe("Rules"); + await expectChecked(lint, true); + + await typecheck.getByText("Typecheck", { exact: true }).dblclick(); + await expectChecked(typecheck, true); + await lint.getByText("Lint", { exact: true }).click(); + await expectChecked(lint, false); + } finally { + await session.cleanup(); + } + }); +}); test.describe("Question prompt pagination", () => { + test("question links open without answering, and the question text is selectable", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + await context.route("https://example.com/**", (route) => + route.fulfill({ status: 200, body: "ok" }), + ); + + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-links-", + title: "Question links e2e", + initialPrompt: "Emit synthetic questions with links.", + }); + + try { + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + + const card = page.getByTestId("question-form-card").first(); + const question = card.getByTestId("question-form-current-question"); + await expect(question).toHaveText(LINKED_QUESTION); + await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible(); + await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible(); + + const bounds = await question.boundingBox(); + if (!bounds) throw new Error("Question text is not visible"); + const y = bounds.y + bounds.height / 2; + await page.mouse.move(bounds.x + 1, y); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 }); + await page.mouse.up(); + await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION); + + const popup = context.waitForEvent("page"); + await question.getByRole("link", { name: "the setup guide" }).click(); + await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup"); + + await expect(page.getByTestId("question-form-card")).toHaveCount(1); + for (const option of ["Done", "Not yet"]) { + await expect(card.getByRole("radio", { name: option })).toHaveAttribute( + "aria-checked", + "false", + ); + } + } finally { + await session.cleanup(); + } + }); + test("shows one question at a time with numbered navigation", async ({ page }) => { test.setTimeout(180_000); @@ -58,6 +264,7 @@ test.describe("Question prompt pagination", () => { await expectQuestionHidden(page, SUCCESS_QUESTION); await chooseQuestionOption(page, "App"); + await continueToNextQuestion(page); await expectCurrentQuestion(page, { index: 2, total: TOTAL_QUESTIONS, @@ -78,6 +285,7 @@ test.describe("Question prompt pagination", () => { preview: 'flags.enabled("new-surface")', }); await chooseQuestionOption(page, "Behind feature flag"); + await continueToNextQuestion(page); await expectCurrentQuestion(page, { index: 3, total: TOTAL_QUESTIONS, @@ -256,7 +464,9 @@ retryTest( retryTest.setTimeout(180_000); const card = page.getByTestId("question-form-card"); await chooseQuestionOption(page, "App"); + await continueToNextQuestion(page); await chooseQuestionOption(page, "Behind feature flag"); + await continueToNextQuestion(page); await fillQuestionAnswer(page, { question: SUCCESS_QUESTION, answer: "Preserve my answers after a failed submission.", @@ -310,7 +520,9 @@ retryTest( const card = page.getByTestId("question-form-card"); questionClient.releaseFirstResponse(); await chooseQuestionOption(page, "App"); + await continueToNextQuestion(page); await chooseQuestionOption(page, "Behind feature flag"); + await continueToNextQuestion(page); await fillQuestionAnswer(page, { question: SUCCESS_QUESTION, answer: "Start with the internal team.", diff --git a/packages/app/e2e/browser/settings-host-page.spec.ts b/packages/app/e2e/browser/settings-host-page.spec.ts index b531232615..d1507a105f 100644 --- a/packages/app/e2e/browser/settings-host-page.spec.ts +++ b/packages/app/e2e/browser/settings-host-page.spec.ts @@ -1,3 +1,12 @@ +import { + openGlobalNewWorkspaceComposer, + fillNewWorkspaceDraft, + expectNewWorkspaceDraft, +} from "../support/helpers/new-workspace"; +import { readFile, mkdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; @@ -25,6 +34,12 @@ import { removeHostFromHostPage, addDirectHostFromSettings, expectHostOnlineWithoutError, + expectIdleAgentRelease, + editRestartRecoveryPrompt, + expectRestartRecoveryEnabled, + expectRestartRecoveryPrompt, + setRestartRecoveryEnabled, + setIdleAgentRelease, } from "../support/helpers/settings"; test.describe("Settings host page", () => { @@ -137,6 +152,100 @@ test.describe("Settings host page", () => { } }); + test("idle agent release changes live on one host and stays saved without restarting", async ({ + page, + }) => { + test.setTimeout(120_000); + const primaryServerId = getServerId(); + const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release"); + const client = await connectDaemonClient({ + clientIdPrefix: "idle-release-settings", + port: daemon.port, + }); + const originalPid = (await client.getDaemonStatus()).pid; + const expectLiveSavedSetting = async (enabled: boolean) => { + const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8")); + expect(saved.agents.idleRuntimeCleanup).toBe(enabled); + expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled); + expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]); + expect((await client.getDaemonStatus()).pid).toBe(originalPid); + }; + const hosts = [ + { + serverId: primaryServerId, + label: TEST_HOST_LABEL, + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + { + serverId: daemon.serverId, + label: "Idle release host", + endpoint: `127.0.0.1:${daemon.port}`, + }, + ]; + const openHostAgentsInFreshApp = async (serverId: string) => { + await seedSavedSettingsHosts(page, hosts); + await page.reload(); + await openSettings(page); + await selectSettingsHost(page, serverId); + await openHostSection(page, serverId, "agents"); + }; + try { + await test.step("idle agent release starts on and turns off", async () => { + await openHostAgentsInFreshApp(daemon.serverId); + await expectIdleAgentRelease(page, true); + await setIdleAgentRelease(page, false); + await expectLiveSavedSetting(false); + }); + + await test.step("a fresh app reads the saved setting from the same running host", async () => { + await openHostAgentsInFreshApp(daemon.serverId); + await expectIdleAgentRelease(page, false); + await expectLiveSavedSetting(false); + await setIdleAgentRelease(page, true); + await expectLiveSavedSetting(true); + await setIdleAgentRelease(page, false); + }); + + await test.step("other hosts keep their own setting", async () => { + await selectSettingsHost(page, primaryServerId); + await openHostSection(page, primaryServerId, "agents"); + await expectIdleAgentRelease(page, true); + await expectLiveSavedSetting(false); + }); + } finally { + await client.close(); + await daemon.close(); + } + }); + + test("a failed idle release save stays visible and retry applies it live", async ({ page }) => { + const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure"); + const configPath = path.join(daemon.paseoHome, "config.json"); + try { + await seedSavedSettingsHosts(page, [ + { serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` }, + ]); + await page.reload(); + await openSettings(page); + await openHostSection(page, daemon.serverId, "agents"); + await expectIdleAgentRelease(page, true); + // A real filesystem failure, with the daemon and RPC path intact. + await rename(configPath, `${configPath}.saved`); + await mkdir(configPath); + await page.getByRole("switch", { name: "Release idle agents" }).click(); + await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText( + "Failed to read", + ); + await expectIdleAgentRelease(page, true); + await rm(configPath, { recursive: true }); + await rename(`${configPath}.saved`, configPath); + await setIdleAgentRelease(page, false); + await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0); + } finally { + await daemon.close(); + } + }); + test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({ page, }) => { @@ -152,3 +261,88 @@ test.describe("Settings host page", () => { await expectHostActionCards(page, serverId); }); }); + +test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => { + const serverId = getServerId(); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "agents"); + await expectRestartRecoveryEnabled(page, true); + await expectRestartRecoveryPrompt(page, "Resume working."); + await editRestartRecoveryPrompt(page, "Continue the interrupted work."); + await setRestartRecoveryEnabled(page, false); + await page.reload(); + await expectRestartRecoveryEnabled(page, false); + await setRestartRecoveryEnabled(page, true); + await expectRestartRecoveryPrompt(page, "Continue the interrupted work."); + await editRestartRecoveryPrompt(page, "Resume working."); +}); + +test("sidebar Add host preserves its caller and stays closed after theme changes", async ({ + page, +}) => { + await gotoAppShell(page); + await expect(page).toHaveURL(/\/open-project$/); + const originalUrl = page.url(); + await openSidebarAddHost(page); + await expect(page).toHaveURL(originalUrl); + await closeAddHostAndChangeTheme(page); + await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden(); + await expect(page).toHaveURL(originalUrl); +}); + +async function openSidebarAddHost(page: import("@playwright/test").Page) { + await page.getByRole("button", { name: "Hosts", exact: true }).click(); + await page.getByRole("button", { name: "Add host", exact: true }).click(); + await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible(); +} + +async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) { + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden(); + await page.keyboard.press("Control+Alt+t"); +} + +test("sidebar Add host completes without navigating", async ({ page }) => { + await gotoAppShell(page); + await expect(page).toHaveURL(/\/open-project$/); + const originalUrl = page.url(); + await openSidebarAddHost(page); + await connectToTestHost(page); + await expect(page).toHaveURL(originalUrl); + await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible(); +}); + +test("Settings Add host opens the saved host Overview", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) }); + await expectSettingsHeader(page, "Overview"); + await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`)); +}); + +test("New workspace Add host preserves the prompt after connecting", async ({ page }) => { + await gotoAppShell(page); + await openGlobalNewWorkspaceComposer(page); + await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host."); + const originalUrl = page.url(); + await openNewWorkspaceAddHost(page); + await expect(page).toHaveURL(originalUrl); + await connectToTestHost(page); + await expect(page).toHaveURL(originalUrl); + await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host."); +}); + +async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) { + await page.getByTestId("host-picker-trigger").click(); + await page.getByRole("button", { name: "Add host", exact: true }).click(); +} + +async function connectToTestHost(page: import("@playwright/test").Page) { + await page.getByRole("button", { name: "Direct connection", exact: true }).click(); + await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1"); + await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort())); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 }); +} diff --git a/packages/app/e2e/browser/settings-host-selection.spec.ts b/packages/app/e2e/browser/settings-host-selection.spec.ts index aa92e5611f..a3e998e940 100644 --- a/packages/app/e2e/browser/settings-host-selection.spec.ts +++ b/packages/app/e2e/browser/settings-host-selection.spec.ts @@ -6,7 +6,10 @@ import { addDirectHostFromSettings, clickSettingsBackToWorkspace, openSettingsHostSection, + openSettingsSection, + selectSettingsHost, } from "../support/helpers/settings"; +import { getServerId } from "../support/helpers/server-id"; import { seedWorkspace } from "../support/helpers/seed-client"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; @@ -44,6 +47,14 @@ test.describe("Settings host selection", () => { await openSettings(page); await openSettingsHostSection(page, remoteDaemon.serverId, "connections"); + + await selectSettingsHost(page, getServerId()); + await openSettingsSection(page, "appearance"); + await openSettingsHostSection(page, getServerId(), "connections"); + + await clickSettingsBackToWorkspace(page); + await openSettings(page); + await openSettingsHostSection(page, remoteDaemon.serverId, "connections"); } finally { await remoteWorkspace.cleanup().catch(() => undefined); await remoteDaemon.close().catch(() => undefined); diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index bc8e1eedec..aa381f1348 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -10,6 +10,15 @@ import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { closeCompactSettings, + scrollSettingsSidebarToEnd, + revealSettingsSidebarSection, + expectSettingsSidebarScrollSettled, + scrollSettingsSidebarUp, + changeSettingsInterfaceFontSize, + selectSettingsHostSectionsRapidly, + expectSettingsSidebarScrollOffset, + scrollSettingsDetailDown, + expectSettingsDetailScrollOffset, openSettingsSection, expectSettingsHeader, openAddHostFlow, @@ -29,6 +38,9 @@ import { fillDirectHostUri, expectDirectHostFormValues, expectDirectHostSslEnabled, + expectDirectHostSslDisabled, + fillDirectHostAddress, + toggleDirectHostSsl, expectDirectHostUriValue, expectDirectHostUriHidden, expectDiagnosticsContent, @@ -43,6 +55,8 @@ import { } from "../support/helpers/settings"; import { getServerId } from "../support/helpers/server-id"; import { expectAppRoute } from "../support/helpers/route-assertions"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; async function openWorkspace( page: import("@playwright/test").Page, @@ -52,7 +66,167 @@ async function openWorkspace( await expect(page.getByTestId("menu-button")).toBeVisible(); } +test.describe("Settings sidebar scroll position", () => { + test.use({ viewport: { width: 1000, height: 500 } }); + + test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await scrollSettingsSidebarToEnd(page); + await page.setViewportSize({ width: 1000, height: 1100 }); + await expectSettingsSidebarScrollOffset(page, 0); + await page.setViewportSize({ width: 1000, height: 500 }); + await openSettingsSection(page, "appearance"); + await expectSettingsSidebarScrollOffset(page, 0); + await scrollSettingsSidebarToEnd(page); + await clickSettingsBackToWorkspace(page); + await openSettings(page); + await expectSettingsSidebarScrollOffset(page, 0); + }); + + test("keeps the sidebar position through interface font changes", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await openSettingsSection(page, "appearance"); + const offset = await scrollSettingsSidebarToEnd(page); + await changeSettingsInterfaceFontSize(page, 20); + await expectSettingsSidebarScrollOffset(page, offset); + await openSettingsHostSection(page, getServerId(), "providers"); + await expectSettingsSidebarScrollOffset(page, offset); + }); + + test("scrolls the sidebar and detail pane independently", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + const sidebarOffset = await scrollSettingsSidebarToEnd(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); + const detailOffset = await scrollSettingsDetailDown(page); + await expectSettingsSidebarScrollOffset(page, sidebarOffset); + + await scrollSettingsSidebarUp(page, 60); + await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60); + await expectSettingsDetailScrollOffset(page, detailOffset); + }); + + test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + const scrollTop = await scrollSettingsSidebarToEnd(page); + + await selectSettingsHostSectionsRapidly(page); + await expectSettingsSidebarScrollOffset(page, scrollTop); + + await expectSettingsSidebarScrollSettled(page, scrollTop); + }); + + test("keeps the sidebar position when switching between app and host sections", async ({ + page, + }) => { + await gotoAppShell(page); + await openSettings(page); + + const hostScrollTop = await scrollSettingsSidebarToEnd(page); + await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport(); + + for (const section of [ + "providers", + "terminals", + "providers", + "terminals", + "providers", + ] as const) { + await openSettingsHostSection(page, getServerId(), section); + await expect(page.getByTestId("page-title")).toHaveText( + section === "providers" ? "Providers" : "Terminals", + ); + await expectSettingsSidebarScrollOffset(page, hostScrollTop); + } + + const sidebar = page.getByTestId("settings-sidebar"); + const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics"); + await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport(); + + await openSettingsSection(page, "about"); + await expectAboutContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + + await openSettingsSection(page, "diagnostics"); + await expectDiagnosticsContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + }); +}); + test.describe("Settings sidebar navigation", () => { + test("opening Settings closes the composer model selector until it is opened again", async ({ + page, + withWorkspace, + }, testInfo) => { + const workspace = await withWorkspace({ prefix: "settings-model-selector-" }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + + const selector = page.getByTestId("combined-model-selector").filter({ visible: true }); + const popover = page.getByTestId("combobox-desktop-container"); + await selector.click(); + await expect(popover).toBeVisible(); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+,`); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") }); + await expect(popover).toHaveCount(0); + + await clickSettingsBackToWorkspace(page); + await expect(selector).toBeVisible(); + await expect(popover).toHaveCount(0); + + await selector.click(); + await expect(popover).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + }); + + test("opening Settings closes an existing chat's model selector until it is opened again", async ({ + page, + }, testInfo) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "settings-existing-model-", + title: "Settings model selector", + }); + try { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const selector = page.getByTestId("combined-model-selector").filter({ visible: true }); + const popover = page.getByTestId("combobox-desktop-container"); + await selector.click(); + await expect(popover).toBeVisible(); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+,`); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.screenshot({ + path: testInfo.outputPath("settings-after-existing-model-selector.png"), + }); + await expect(popover).toHaveCount(0); + + await clickSettingsBackToWorkspace(page); + await expect(selector).toBeVisible(); + await expect(popover).toHaveCount(0); + + await selector.click(); + await expect(popover).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { await gotoAppShell(page); await openSettings(page); @@ -77,6 +251,28 @@ test.describe("Settings sidebar navigation", () => { await expect(page).not.toHaveURL(/\/settings(\/|$)/); }); + test("host choice survives app sections and host screen replacements", async ({ page }) => { + const primaryServerId = getServerId(); + const secondaryServerId = "srv_e2e_settings_secondary"; + const endpoint = `127.0.0.1:${getE2EDaemonPort()}`; + await seedSavedSettingsHosts(page, [ + { serverId: primaryServerId, label: "First horse", endpoint }, + { serverId: secondaryServerId, label: "Stable horse", endpoint }, + ]); + await gotoAppShell(page); + await openSettings(page); + await selectSettingsHost(page, secondaryServerId); + await openSettingsHostSection(page, secondaryServerId, "connections"); + await openSettingsSection(page, "appearance"); + await expectSettingsHostPickerLabel(page, "Stable horse"); + await openSettingsSection(page, "general"); + await expectSettingsHostPickerLabel(page, "Stable horse"); + await openSettingsHostSection(page, secondaryServerId, "connections"); + await selectSettingsHost(page, primaryServerId); + await openSettingsSection(page, "about"); + await expectSettingsHostPickerLabel(page, "First horse"); + }); + test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => { await gotoAppShell(page); await verifyLegacyHostSettingsRedirect(page); @@ -91,6 +287,16 @@ test.describe("Settings sidebar navigation", () => { await expectAddHostMethodOptions(page); await selectHostConnectionType(page, "direct"); + await fillDirectHostAddress(page, "example.paseo.test", "7443"); + await expectDirectHostSslDisabled(page); + await toggleDirectHostSsl(page); + await expectDirectHostSslEnabled(page); + await toggleHostAdvanced(page); + await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true"); + await toggleHostAdvanced(page); + await toggleDirectHostSsl(page); + await expectDirectHostSslDisabled(page); + await toggleHostAdvanced(page); await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret"); await toggleHostAdvanced(page); @@ -147,6 +353,42 @@ test.describe("Settings sidebar navigation", () => { test.describe("Settings — compact master-detail", () => { test.use({ viewport: { width: 390, height: 844 } }); + test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "settings-tab-switcher-", + title: "Compact tab switcher", + }); + try { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }); + const newAgent = page.getByTestId("workspace-tab-switcher-new-agent"); + await switcher.click(); + await expect(newAgent).toBeInViewport(); + + // Navigate without dismissing the sheet through its backdrop first. + await page + .getByTestId("menu-button") + .filter({ visible: true }) + .first() + .dispatchEvent("click"); + await page.getByTestId("sidebar-settings").first().dispatchEvent("click"); + await expectAppRoute(page, buildSettingsRoute()); + await expect(newAgent).toHaveCount(0); + + await closeCompactSettings(page); + await expect(switcher).toBeVisible(); + await expect(newAgent).toHaveCount(0); + await switcher.click(); + await expect(newAgent).toBeInViewport(); + } finally { + await workspace.cleanup(); + } + }); + test("opens compact app and host details and returns through the settings list", async ({ page, }) => { @@ -223,6 +465,15 @@ test.describe("Settings — compact master-detail", () => { await expectSettingsHostPickerLabel(page, secondaryHostLabel); await openSettingsHostSection(page, secondaryServerId, "connections"); + await goBackInSettings(page); + await expectSettingsHostPickerLabel(page, secondaryHostLabel); + + await openSettingsSection(page, "appearance"); + await goBackInSettings(page); + await expectSettingsHostPickerLabel(page, secondaryHostLabel); + await openSettingsHostSection(page, secondaryServerId, "connections"); + await goBackInSettings(page); + await expectSettingsHostPickerLabel(page, secondaryHostLabel); }); test("removing the last active host returns to welcome after settings closes", async ({ @@ -241,3 +492,76 @@ test.describe("Settings — compact master-detail", () => { await expect(page.getByTestId("welcome-direct-connection")).toBeVisible(); }); }); + +async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) { + await page.setViewportSize({ width, height: 951 }); + const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true }); + const settings = page + .getByRole("button", { name: "Settings", exact: true }) + .filter({ visible: true }); + await expect(history).toBeVisible(); + await expect(settings).toBeVisible(); + return { + width, + history: await history.evaluate((el) => el.getBoundingClientRect().height), + settings: await settings.evaluate((el) => el.getBoundingClientRect().height), + }; +} + +async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) { + await page.setViewportSize({ width, height: 951 }); + const sidebar = page.getByTestId("settings-sidebar"); + const detail = page.getByTestId("settings-detail-pane"); + await expect(sidebar).toBeVisible(); + await expect(detail).toBeVisible(); + return { + width, + sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width), + detail: await detail.evaluate((el) => el.getBoundingClientRect().width), + }; +} + +async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) { + const wide = await measureDockedNavigation(page, 951); + for (const width of [600, 669, 719, 720]) { + const narrow = await measureDockedNavigation(page, width); + expect + .soft(narrow.history, `History keeps its wide-layout density at ${width}`) + .toBeCloseTo(wide.history, 0); + expect + .soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`) + .toBeCloseTo(wide.settings, 0); + } +} + +async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) { + const reference = await measureSettingsSplit(page, 720); + for (const width of [600, 669, 719]) { + const narrow = await measureSettingsSplit(page, width); + expect + .soft(narrow.detail, `Settings preserves its detail budget at ${width}`) + .toBeGreaterThanOrEqual(reference.detail); + expect + .soft(narrow.sidebar, `Settings navigation yields width at ${width}`) + .toBeLessThan(reference.sidebar); + } + const wide = await measureSettingsSplit(page, 951); + expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0); + expect(wide.detail).toBeGreaterThan(reference.detail); +} + +test("docked navigation keeps its density and Settings preserves reading width", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" }); + await gotoWorkspace(page, workspace.workspaceId); + await expectConsistentDockedNavigation(page); + await openSettings(page); + await openSettingsSection(page, "appearance"); + await expectAppearanceContent(page); + await expectSettingsDetailKeepsReadingWidth(page); + await page.setViewportSize({ width: 599, height: 951 }); + await expectSettingsSidebarHidden(page); + await expectAppearanceContent(page); +}); diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 2c1fd0cc24..35ec6f78bb 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,3 +1,4 @@ +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; @@ -10,6 +11,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page await page.keyboard.press(`${modifier}+Comma`); } +async function openComposerTab(page: import("@playwright/test").Page, agentId: string) { + await page + .getByTestId(`workspace-tab-agent_${agentId}`) + .filter({ visible: true }) + .first() + .click(); + await expectAgentTabActive(page, agentId); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); +} + interface ComposerFrame { at: number; visible: boolean; @@ -27,7 +38,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page) const sample = () => { if (capture.stopped) return; - const root = document.querySelector('[data-testid="message-input-root"]'); + const root = Array.from( + document.querySelectorAll('[data-testid="message-input-root"]'), + ).find((candidate) => candidate.offsetParent !== null); const input = root?.querySelector("textarea"); if (root && input) { const style = getComputedStyle(input); @@ -178,6 +191,8 @@ test.describe("Settings toggle tab regression", () => { await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); await waitForTabBar(page); + await openComposerTab(page, firstAgent.id); + await openComposerTab(page, secondAgent.id); const input = page.getByRole("textbox", { name: "Message agent..." }); await input.fill("Keep this short draft in the composer"); const root = page.getByTestId("message-input-root").filter({ visible: true }).first(); @@ -194,6 +209,7 @@ test.describe("Settings toggle tab regression", () => { const frames = await stopComposerFrameCapture(page); const visibleFrames = frames.filter((frame) => frame.visible); + expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0); const tallestIndex = visibleFrames.reduce( (index, frame, candidateIndex) => frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index, @@ -259,3 +275,123 @@ test.describe("Settings toggle tab regression", () => { } }); }); + +async function selectSendingOption( + page: import("@playwright/test").Page, + setting: string, + option: string, +) { + await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click(); + await page.getByRole("menuitem", { name: option, exact: true }).click(); + await expect( + page.getByRole("button", { name: `${setting}: ${option}`, exact: true }), + ).toBeVisible(); +} + +async function sendDraftWithShortcut( + page: import("@playwright/test").Page, + draft: string, + shortcut: string, +) { + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill(draft); + await composer.press(shortcut); + await expect(composer).toHaveValue(""); + await expect(page.getByText(draft, { exact: true })).toBeVisible(); +} + +test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => { + test.setTimeout(180_000); + const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" }); + const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl"; + try { + const agent = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Send shortcut", + }); + await openAgentRouteAndExpectFocused({ + page, + serverId: getServerId(), + workspaceId: workspace.workspaceId, + agentId: agent.id, + }); + await openComposerTab(page, agent.id); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + + await test.step("Enter sends by default", async () => { + await sendDraftWithShortcut(page, "Default Enter submission", "Enter"); + }); + await test.step("choose the explicit modifier shortcut and retain it after reload", async () => { + await pressSettingsToggleShortcut(page); + await expect( + page.getByRole("button", { name: "Send shortcut: Enter", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click(); + await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]); + // Let the menu opening animation settle for visual QA. + await page.waitForTimeout(300); + await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") }); + await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click(); + await page.reload(); + await expect( + page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }), + ).toBeVisible(); + await pressSettingsToggleShortcut(page); + await expect(composer).toBeVisible(); + }); + await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => { + await composer.fill("First line"); + await composer.press("Enter"); + await expect(composer).toHaveValue("First line\n"); + await composer.press("Shift+Enter"); + await expect(composer).toHaveValue("First line\n\n"); + await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter"); + await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter"); + }); + await test.step("switch back to Enter without changing the default send behavior", async () => { + await pressSettingsToggleShortcut(page); + await selectSendingOption(page, "Send shortcut", "Enter"); + await pressSettingsToggleShortcut(page); + await composer.fill("Shift still edits"); + await composer.press("Shift+Enter"); + await expect(composer).toHaveValue("Shift still edits\n"); + await sendDraftWithShortcut(page, "Restored Enter submission", "Enter"); + }); + } finally { + await workspace.cleanup(); + } +}); + +for (const behavior of ["Steer", "Interrupt", "Queue"] as const) { + test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "running-send-shortcut-", + title: "Running send shortcut", + model: "one-minute-stream", + }); + const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl"; + try { + await openAgentRoute(page, agent); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await pressSettingsToggleShortcut(page); + await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`); + await selectSendingOption(page, "Default send", behavior); + await pressSettingsToggleShortcut(page); + await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter"); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible(); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill("Follow-up"); + await composer.press("Enter"); + await expect(composer).toHaveValue("Follow-up\n"); + await composer.press("Control+Enter"); + await expect(composer).toHaveValue(""); + await expect(page.getByText("Follow-up", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount( + behavior === "Queue" ? 1 : 0, + ); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts index 7b4c36bf97..92e4d7a095 100644 --- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise { test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => { const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" }); + const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" }); try { await gotoAppShell(page); @@ -24,8 +25,10 @@ test("project and workspace kebabs open action sheets on compact layouts", async await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", + "Project", "Show background", ]); await closeMenuSheet(page); @@ -53,6 +56,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async }); await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible(); } finally { + await secondProject.cleanup(); await seeded.cleanup(); } }); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index a17480b740..b557b73ccb 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -1,10 +1,12 @@ import { expect, test } from "../support/fixtures"; +import type { Page } from "@playwright/test"; import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, expectPinnedUsage, openCompactSidebar, + expectCompactSidebarOpen, } from "../support/helpers/usage-sidebar-item"; import { gotoAppShell } from "../support/helpers/app"; import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items"; @@ -31,6 +33,28 @@ import { setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; +async function showDeveloperToast(page: Page): Promise { + await page.evaluate(() => { + document.getElementById("error-toast")?.remove(); + const toast = document.createElement("div"); + toast.id = "error-toast"; + toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white"; + // Expo renders its unnamed dismiss button in an open shadow root. + const button = document.createElement("button"); + button.style.cssText = "width:24px;height:24px"; + button.addEventListener("click", () => toast.remove()); + toast.attachShadow({ mode: "open" }).appendChild(button); + document.body.appendChild(toast); + }); +} + +test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => { + await gotoAppShell(page); + await showDeveloperToast(page); + await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]); + await expect(page.locator("#error-toast")).toBeHidden(); +}); + test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); @@ -67,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy await openAddProjectFlow(page); await page.keyboard.press("Escape"); await expect(addProjectFlow(page)).toBeHidden(); + await expectCompactSidebarOpen(page); await page.setViewportSize({ width: 1440, height: 900 }); await openSidebarNavSettings(page); await setFooterItemVisible(page, "usage", true); await leaveSettings(page); await page.setViewportSize({ width: 390, height: 844 }); - await openCompactSidebar(page); + await expectCompactSidebarOpen(page); await expectFooterSeparator(page, true); await expectFooterIconRow(page); await footerScreenshot(page, "footer-compact-with-rows"); diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts index a2f8d14348..e406ba5a3f 100644 --- a/packages/app/e2e/browser/sidebar-project-filter.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts @@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => { await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-project-grouping.spec.ts b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts index bd9b934aad..6243d52fdf 100644 --- a/packages/app/e2e/browser/sidebar-project-grouping.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts @@ -9,6 +9,8 @@ import { expectProjectWorkspaceCountForHost, expectSeparateProjects, openGroupedProjectSettings, + openGroupedProjectWorkspace, + expectWorkspaceHost, openProjectsForSettingsHost, openProjectDirectory, openProjectDirectoryWithHosts, @@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => { count: 2, }); }); + test("creates grouped project workspaces on the active workspace host", async ({ + page, + crossHostProject, + }) => { + await openScenario(page, crossHostProject); + await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace"); + await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME); + await expectWorkspaceHost(page, SECONDARY_HOST_LABEL); + await createWorkspaceWithoutAgent(page); + await expectProjectWorkspaceCountForHost(page, { + projectName: GROUPED_PROJECT_NAME, + hostName: SECONDARY_HOST_LABEL, + count: 2, + }); + + await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace"); + await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME); + await expectWorkspaceHost(page, PRIMARY_HOST_LABEL); + await createWorkspaceWithoutAgent(page); + await expectProjectWorkspaceCountForHost(page, { + projectName: GROUPED_PROJECT_NAME, + hostName: PRIMARY_HOST_LABEL, + count: 2, + }); + }); }); diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index 6129d37a63..cf885a1d14 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -1,9 +1,10 @@ -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { getServerId } from "../support/helpers/server-id"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; async function rowTestIds(rows: Locator) { @@ -52,12 +53,31 @@ async function quickDragFirstRowAfterSecond( await page.mouse.move(source.x, source.y + 7); await expect(trailingScrim).toHaveCount(0); await page.mouse.move(target.x, target.y, { steps: 4 }); - await page.mouse.up(); + await finishSidebarDrag(page); await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); } -test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({ +async function finishSidebarDrag(page: Page): Promise { + await page.mouse.up(); + // dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop. + // Row order updates before that blocker is removed; wait before the next user action. + await page.waitForTimeout(60); +} + +async function dragWithoutReordering(rows: Locator): Promise { + const before = await rowTestIds(rows); + const source = await visibleBoundingBox(rows.nth(0)); + const target = await visibleBoundingBox(rows.nth(1)); + const page = rows.page(); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 }); + await finishSidebarDrag(page); + await expect.poll(() => rowTestIds(rows)).toEqual(before); +} + +test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({ page, }) => { const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" }); @@ -87,12 +107,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra ); const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; - await quickDragFirstRowAfterSecond( - page.locator( - `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, - ), - pressWorkspaceRow, + const ordinaryRows = page.locator( + `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, ); + await expect + .poll(() => rowTestIds(ordinaryRows)) + .toEqual([secondWorkspaceTestId, firstWorkspaceTestId]); + await dragWithoutReordering(ordinaryRows); await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true); await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true); @@ -108,3 +129,378 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra await secondProject.cleanup(); } }); + +async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await page.getByRole("menuitem", { name, exact: true }).click(); +} + +async function reconnectSidebar(page: Page): Promise { + await page.reload(); + await waitForSidebarHydration(page); +} + +async function expectNoWorkspaceSortChoice(page: Page): Promise { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} + +async function promptWorkspace(seed: Awaited>, agentId: string) { + await seed.client.sendAgentMessage(agentId, "sidebar ordering check"); + expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle"); +} + +test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({ + page, +}) => { + test.setTimeout(120_000); + const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" }); + try { + const newer = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "Newer workspace", + }); + if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed"); + const agent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Ordering agent", + }); + const newerAgent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: newer.workspace.id, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Newer agent", + }); + await gotoAppShell(page); + await waitForSidebarHydration(page); + const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`; + const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`; + const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectWorkspaceSort(page, "Last prompt"); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await promptWorkspace(seed, newerAgent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await seed.client.archiveAgent(agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectSidebarStatusGrouping(page); + await expectNoWorkspaceSortChoice(page); + } finally { + await seed.cleanup(); + } +}); + +async function seedCompactProject() { + const seed = await seedWorkspace({ + repoPrefix: "sidebar-visibility-", + title: "Oldest workspace", + }); + const ids = [seed.workspaceId]; + for (let index = 2; index <= 12; index += 1) { + const created = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: `Workspace ${index}`, + }); + if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed"); + ids.unshift(created.workspace.id); + } + const agent = await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: "Overflow agent", + }); + return { seed, ids, agent }; +} + +async function expectWorkspaceOrder(rows: Locator, ids: string[]) { + await expect + .poll(() => rowTestIds(rows)) + .toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`)); +} + +async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") { + await list.getByLabel(action, { exact: true }).click(); +} + +async function selectProjectGrouping(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Group/ }).click(); + await page.getByTestId("sidebar-grouping-project").click(); +} + +type CompactProject = Awaited>; + +function compactProjectRows(page: Page, seed: CompactProject["seed"]) { + const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`); + return { + rows: list.locator('[data-testid^="sidebar-workspace-row-"]'), + group: list.locator(".."), + project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`), + }; +} + +async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) { + const { rows } = compactProjectRows(page, seed); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + + await seed.client.sendAgentMessage(agent.id, "show active overflow"); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.waitForFinish(agent.id, 15_000); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectRevealSurvivesSortingAndCollapse( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows, group, project } = compactProjectRows(page, seed); + const retainedRows = await rows.elementHandles(); + await revealWorkspaces(group, "Show more"); + await expectWorkspaceOrder(rows, ids); + for (const [index, retained] of retainedRows.entries()) { + expect( + await rows.nth(index).evaluate((current, previous) => current === previous, retained), + ).toBe(true); + } + await project.click(); + await expect(rows).toHaveCount(0); + await project.click(); + await expectWorkspaceOrder(rows, ids); + await seed.client.sendAgentMessage(agent.id, "expanded overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids); + await selectWorkspaceSort(page, "Last prompt"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]); + const revealedRow = await rows.nth(8).elementHandle(); + await revealWorkspaces(group, "Show less"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]); + await selectWorkspaceSort(page, "Created"); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectStatusBucketsRetainActiveOverflow( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows } = compactProjectRows(page, seed); + await selectSidebarStatusGrouping(page); + const done = page.getByTestId("sidebar-status-group-rows-done"); + const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]'); + await expect(doneRows).toHaveCount(7); + await revealWorkspaces(done, "Show more"); + await expect(doneRows).toHaveCount(12); + await seed.client.sendAgentMessage(agent.id, "status overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await expect( + page + .getByTestId("sidebar-status-group-rows-attention") + .getByText("Oldest workspace", { exact: true }), + ).toBeVisible(); + await expect(doneRows).toHaveCount(11); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expect(doneRows).toHaveCount(12); + await revealWorkspaces(done, "Show less"); + await expect(doneRows).toHaveCount(7); + const activeAgents: Array<{ id: string }> = []; + for (const workspaceId of ids.slice(0, 8)) { + activeAgents.push( + await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId, + title: "Active overflow agent", + }), + ); + } + await Promise.all( + activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")), + ); + await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000))); + const attention = page.getByTestId("sidebar-status-group-rows-attention"); + await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8); + await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0); + for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId); + await expect(doneRows).toHaveCount(7); + await selectProjectGrouping(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +test("compact groups retain active overflow and reveal the current ordered rows", async ({ + page, +}) => { + test.setTimeout(120_000); + const fixture = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await test.step("Active overflow appears without intervening Done rows", () => + expectActiveOverflowTransitions(page, fixture)); + await test.step("Reveal follows current sort and survives collapse", () => + expectRevealSurvivesSortingAndCollapse(page, fixture)); + await test.step("Status reveals Done rows and keeps every active row", () => + expectStatusBucketsRetainActiveOverflow(page, fixture)); + } finally { + await fixture.seed.cleanup(); + } +}); + +async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) { + await page.evaluate( + ({ projectKey: scope, workspaceKeys: order }) => { + localStorage.setItem( + "sidebar-project-workspace-order", + JSON.stringify({ + version: 1, + state: { + projectOrder: [scope], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { [scope]: order }, + }, + }), + ); + }, + { projectKey, workspaceKeys }, + ); + await reconnectSidebar(page); +} + +test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({ + page, +}) => { + const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" }); + try { + const second = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "New manual workspace", + }); + if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed"); + const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`); + const ids = keys.map((key) => `sidebar-workspace-row-${key}`); + const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", ")); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Last prompt"); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await selectWorkspaceSort(page, "Created"); + await reconnectSidebar(page); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + } finally { + await seed.cleanup(); + } +}); + +function workspaceRows(page: Page, ids: string[]) { + return page.locator( + ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "), + ); +} + +async function openWorkspaceRow(page: Page, workspaceId: string) { + await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click(); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`)); +} + +test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({ + page, +}) => { + test.setTimeout(120_000); + const { seed, ids } = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await selectWorkspaceSort(page, "Created"); + const { rows, group } = compactProjectRows(page, seed); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + await revealWorkspaces(group, "Show more"); + await openWorkspaceRow(page, ids[9]!); + await expectWorkspaceOrder(rows, ids); + await revealWorkspaces(group, "Show less"); + await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`), + ).toHaveAttribute("aria-selected", "true"); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`), + ).toHaveAttribute("aria-selected", "true"); + await openWorkspaceRow(page, ids[2]!); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`), + ).toHaveAttribute("aria-selected", "true"); + } finally { + await seed.cleanup(); + } +}); + +test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({ + page, +}) => { + test.setTimeout(120_000); + const { seed, ids } = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + const projectKey = projectEquivalenceViewKey(seed.projectKey); + await restorePreBetaOrder( + page, + projectKey, + ids.map((id) => `${getServerId()}:${id}`), + ); + await selectWorkspaceSort(page, "Manual"); + const rows = workspaceRows(page, ids); + const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + await toggle.click(); + await openWorkspaceRow(page, ids[9]!); + await toggle.click(); + await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]); + await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow); + await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]); + await toggle.click(); + await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]); + } finally { + await seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts new file mode 100644 index 0000000000..757b8908c2 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -0,0 +1,87 @@ +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { randomUUID } from "node:crypto"; +import { metroTest as test, expect } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; + +test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => { + test.setTimeout(180_000); + const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, { + publishedVersion: "0.8.0", + }); + try { + const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port }); + try { + const second = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath, projectId: project.projectId }, + title: "Second old workspace", + }); + expect(second.workspace).toBeTruthy(); + await openPublishedHost(page, daemon, project.workspaceId); + await openWorkspace(page, daemon.serverId, second.workspace!.id); + await expectSortingRequiresHostUpdate(page); + await selectSidebarStatusGrouping(page); + await expectStatusHasNoSorting(page); + } finally { + await project.cleanup(); + } + } finally { + await daemon.close(); + } +}); + +async function openPublishedHost( + page: Page, + daemon: { serverId: string; port: number }, + workspaceId: string, +) { + const host = buildSeededHost({ + serverId: daemon.serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript((seededHost) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem( + "sidebar-view", + JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }), + ); + }, host); + await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId)); +} + +async function openWorkspace(page: Page, serverId: string, workspaceId: string) { + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(new RegExp(workspaceId)); +} + +async function expectSortingRequiresHostUpdate(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeDisabled(); + await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); + await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.getByRole("menuitem", { name: "Manual", exact: true }).click(); + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual"); + await page.keyboard.press("Escape"); +} + +async function expectStatusHasNoSorting(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount( + 0, + ); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index bc3d4bbf95..ccf29179b8 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -6,6 +6,7 @@ import { seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; +import { expectOpenTabCount } from "../support/helpers/sheet"; import { getServerId } from "../support/helpers/server-id"; import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; @@ -179,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) { async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) { await page.setViewportSize({ width: 390, height: 844 }); await openAgentRoute(page, workspace); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await openMobileAgentSidebar(page); } @@ -227,3 +229,45 @@ test("manual unread survives leaving the current workspace on compact layout", a await markUnreadThenResumeChat(page, workspaces.subject.workspaceId); await completeTurnAndLeaveOnCompact(page, workspaces); }); + +async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { + const route = page.url(); + // End this app session before the turn, so its cached transcript cannot contain the new prompt. + await page.goto("about:blank"); + await workspace.client.sendAgentMessage( + workspace.agentId, + "A turn completed while the app was closed.", + ); + await workspace.client.waitForFinish(workspace.agentId, 20_000); + // Reopen the ordinary workspace route, without an agent open intent that would repair focus. + await page.goto(route); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible(); +} + +async function expectRestoredCompactChat(page: Page) { + await expectOpenTabCount(page, 1); + await expect( + page.getByText("A turn completed while the app was closed.", { exact: true }), + ).toBeVisible(); +} + +test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({ + page, + workspaces, +}) => { + test.setTimeout(120_000); + await test.step("open a finished chat with one main tab", async () => { + await openCompactWorkspace(page, workspaces.subject); + await closeMobileAgentSidebar(page); + await expectOpenTabCount(page, 1); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + }); + await test.step("mark the current workspace unread, then close and reopen the app", async () => { + await openMobileAgentSidebar(page); + await markAsUnread(page, workspaces.subject.workspaceId); + await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject); + }); + await test.step("restore the main tab without Explorer entries and load its latest history", async () => { + await expectRestoredCompactChat(page); + }); +}); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 8071b3b4f3..d96f68580c 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => { await expectMobileAgentSidebarHidden(page); }); + test("places the close button beside the first nav row instead of over it", async ({ page }) => { + await gotoAppShell(page); + await openMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + + const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox(); + const closeButton = await page.getByTestId("sidebar-close").boundingBox(); + if (!firstRow || !closeButton) throw new Error("first nav row and close button must render"); + expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width); + }); + test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({ page, }) => { @@ -613,7 +624,9 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); }); - test("keeps app navigation beside the Explorer pane", async ({ page }) => { + test("yields app navigation to Explorer and restores it when Explorer closes", async ({ + page, + }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); try { @@ -625,7 +638,7 @@ test.describe("Half-screen desktop layout", () => { const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar"); - await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); await expect( page diff --git a/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts new file mode 100644 index 0000000000..90420c6fd9 --- /dev/null +++ b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts @@ -0,0 +1,134 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; +import { createCreationScenario } from "../support/helpers/creation"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +const DRAG_DISTANCE = -40; +const TOLERANCE_PX = 2; + +function splitDividers(page: Page): Locator { + return page.getByTestId("workspace-split-resize-handle").filter({ visible: true }); +} + +async function dividerX(divider: Locator): Promise { + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + return box.x; +} + +async function dragDivider(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + deltaX, y, { steps: 8 }); + await page.mouse.up(); +} + +async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const input = await page.context().newCDPSession(page); + try { + await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); + for (let step = 1; step <= 8; step++) { + await input.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: x + (deltaX * step) / 8, y }], + }); + } + await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + } finally { + await input.detach(); + } +} + +async function splitIntoFourPanes(page: Page): Promise { + for (const expectedDividers of [1, 2, 3]) { + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await expect(splitDividers(page)).toHaveCount(expectedDividers); + } +} + +async function expectDividersTrackPointer( + page: Page, + distance = DRAG_DISTANCE, + drag = dragDivider, +): Promise { + for (const index of [0, 1, 2]) { + const divider = splitDividers(page).nth(index); + const before = await dividerX(divider); + await drag(divider, distance); + await expect + .poll(async () => Math.abs((await dividerX(divider)) - before - distance), { + message: `divider ${index + 1} should stay under the pointer`, + }) + .toBeLessThanOrEqual(TOLERANCE_PX); + } +} + +for (const hasTouch of [false, true]) { + test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => { + test.use({ hasTouch }); + + test("a divider moves as far as the pointer in the main row", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await clickNewChat(page); + await splitIntoFourPanes(page); + await expectDividersTrackPointer( + page, + DRAG_DISTANCE, + hasTouch ? dragDividerWithTouch : dragDivider, + ); + } finally { + await workspace.cleanup(); + } + }); + + test("dividers track the pointer after app creation, Explorer changes and reload", async ({ + page, + }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + const creation = await createCreationScenario(page); + try { + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Reply briefly.", "Create"); + await creation.expectAssistantReply(); + await waitForWorkspaceTabsVisible(page); + await splitIntoFourPanes(page); + await expectDividersTrackPointer(page); + await page + .getByTestId("workspace-explorer-toggle") + .filter({ visible: true }) + .first() + .click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expectDividersTrackPointer(page, -DRAG_DISTANCE); + await page + .getByTestId("workspace-explorer-toggle") + .filter({ visible: true }) + .first() + .click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible(); + await page.reload(); + await expect(splitDividers(page)).toHaveCount(3); + await expectDividersTrackPointer(page); + } finally { + await creation.cleanup(); + } + }); + }); +} diff --git a/packages/app/e2e/browser/subagent-origin-pane.spec.ts b/packages/app/e2e/browser/subagent-origin-pane.spec.ts index 47a0120da6..545076ab06 100644 --- a/packages/app/e2e/browser/subagent-origin-pane.spec.ts +++ b/packages/app/e2e/browser/subagent-origin-pane.spec.ts @@ -1,10 +1,19 @@ +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { submitMessage } from "../support/helpers/composer"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents"; +import { + delegateFleetThroughMcp, + expectManagedSubagentArchived, + openSubagentsTrack, + seedParentWithSubagent, +} from "../support/helpers/subagents"; + +test.use({ e2eInjectPaseoTools: true }); const SETTINGS_KEY = "@paseo:app-settings"; @@ -116,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat } }); +test("a subagent's parent pill returns to its parent", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" }); + try { + const pair = await seedParentWithSubagent(workspace, { + parentTitle: "Refactor auth flow", + childTitle: "Write session tests", + }); + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id }); + + const pill = page.getByTestId("composer-parent-agent-pill"); + await expect(pill).toHaveAccessibleName("Back to Refactor auth flow"); + await pill.click(); + + await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute( + "aria-selected", + "true", + ); + } finally { + await workspace.cleanup(); + } +}); + test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => { const target = "linked.ts:3"; const workspace = await seedMockAgentWorkspace({ @@ -147,3 +179,189 @@ test("a preferred chat file link still opens beside its right-pane agent", async await workspace.cleanup(); } }); + +test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({ + page, +}) => { + const fleet = await delegateFleetThroughMcp(); + let appSockets = 0; + let routeOpens = 0; + const activeDaemonSockets = new Set(); + const observationRequests: Record = {}; + page.on("websocket", (socket) => { + appSockets++; + if (new URL(socket.url()).pathname === "/ws") { + activeDaemonSockets.add(socket); + socket.on("close", () => activeDaemonSockets.delete(socket)); + } + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string" || !payload.startsWith("{")) return; + if (String(payload).includes('"hosts.route.open.request"')) routeOpens++; + const outer = JSON.parse(String(payload)); + const request = outer.type === "session" ? outer.message : null; + if ( + request?.type && + (request.subscribe !== undefined || + request.observe === true || + request.type.endsWith("set_subscription.request")) + ) + observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1; + }); + }); + try { + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await openSubagentsTrack(page); + await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText( + "MCP child on A", + ); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "MCP child on B", + ); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "Fleet B", + ); + await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click(); + await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible(); + await submitMessage(page, "LOCAL_UI_TARGET"); + await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull(); + await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click(); + await openSubagentsTrack(page); + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click(); + await expect(page).toHaveURL((url) => + url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`), + ); + await submitMessage(page, "REMOTE_UI_TARGET"); + await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull(); + await fleet.expectCompletion(fleet.local); + await fleet.expectCompletion(fleet.remote); + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await expect( + page.getByRole("button", { name: /Agent finished.*MCP child on A/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Agent finished.*MCP child on B/ }), + ).toBeVisible(); + const owners = await page.evaluate(() => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as { + getHosts(): Array<{ serverId: string }>; + getSnapshot( + serverId: string, + ): { connectionStatus: string; clientGeneration: number } | null; + }; + return runtime.getHosts().map((host) => { + const snapshot = runtime.getSnapshot(host.serverId); + return { + serverId: host.serverId, + connectionStatus: snapshot?.connectionStatus, + clientGeneration: snapshot?.clientGeneration, + }; + }); + }); + console.log("Fleet ordinary route counts", { + appSockets, + activeDaemonSockets: activeDaemonSockets.size, + routeOpens, + owners, + observationRequests, + }); + } finally { + await fleet.close(); + } +}); + +async function openFleetParentWithDirectB( + page: Page, + fleet: Awaited>, +) { + await page.goto("/", { waitUntil: "commit" }); + const host = buildSeededHost({ + serverId: fleet.remote.serverId, + endpoint: `127.0.0.1:${fleet.destination.port}`, + label: "Fleet B", + nowIso: new Date().toISOString(), + }); + await page.evaluate((extraHost) => { + localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost])); + }, host); + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await openSubagentsTrack(page); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "MCP child on B", + ); +} + +async function openFleetChild( + page: Page, + fleet: Awaited>, +) { + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click(); + await expect(page).toHaveURL( + (url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`, + ); +} + +test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => { + const fleet = await delegateFleetThroughMcp(); + try { + await openFleetParentWithDirectB(page, fleet); + await openFleetChild(page, fleet); + await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible(); + await submitMessage(page, "DIRECT_B_TARGET"); + await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull(); + await openFleetParentWithDirectB(page, fleet); + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover(); + page.once("dialog", (dialog) => { + void dialog.accept(); + }); + await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click(); + await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull(); + await expect(page.getByText(/already connected through another route/)).toHaveCount(0); + } finally { + await fleet.close(); + } +}); + +test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({ + page, +}) => { + const fleet = await delegateFleetThroughMcp(); + try { + await openFleetParentWithDirectB(page, fleet); + await fleet.destination.close(); + await expect + .poll(() => + page.evaluate((serverId) => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as { + getSnapshot(id: string): { serverId: string; connectionStatus: string } | null; + }; + return runtime.getSnapshot(serverId); + }, fleet.remote.serverId), + ) + .toMatchObject({ + serverId: fleet.remote.serverId, + connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/), + }); + await openFleetChild(page, fleet); + await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText( + "B workspace", + ); + await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible(); + await expect(page.getByText(/already connected through another route/)).toHaveCount(0); + } finally { + await fleet.close(); + } +}); diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 023bf12b9d..d5a0071189 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -1,6 +1,10 @@ +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; +import { holdFileUploadResponse } from "../support/helpers/file-upload-gate"; +import { installLocalDesktopHost } from "../support/helpers/local-desktop-host"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; @@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => { setTimeout(finish, 700); `; +// A TUI stand-in: turns on bracketed paste, reads every quoted path in each +// bracketed paste, and echoes typed lines, so the PTY output proves what reached +// the terminal and where the file was read from. +const FILE_RECEIVER_SCRIPT = String.raw` +const fs = require("node:fs"); +const crypto = require("node:crypto"); +let buffer = ""; +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n"); +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer); + if (!paste) break; + for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) { + const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); + const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local"; + process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n"); + } + buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length); + } + if (buffer.includes("\x03")) { + process.stdout.write("INTERRUPTED\r\n"); + buffer = buffer.replaceAll("\x03", ""); + } + const enter = buffer.indexOf("\r"); + if (enter >= 0 && !buffer.includes("'")) { + process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n"); + buffer = buffer.slice(enter + 1); + } +}); +`; + +function pngBytes(seed: number): Buffer { + return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]); +} + +function fileRead(bytes: Buffer, location: "upload" | "local"): string { + const digest = createHash("sha256").update(bytes).digest("hex"); + return `FILE_READ:${digest}:${location}`; +} + +// Inactive terminal tabs retain their surfaces, and another page opening a terminal in +// the same workspace adds a tab here too. Only the selected tab's surface is visible. +function terminalSurface(page: Page) { + return page.getByTestId("terminal-surface").filter({ visible: true }); +} + +function terminalInput(page: Page) { + return terminalSurface(page).locator(".xterm-helper-textarea"); +} + +async function pasteImage(page: Page, bytes: Buffer): Promise { + await terminalInput(page).evaluate( + (element, data) => { + (element as HTMLTextAreaElement).focus(); + const clipboardData = new DataTransfer(); + clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" })); + element.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }), + ); + }, + [...bytes], + ); +} + +interface DroppedFile { + name: string; + type: string; + bytes: Buffer; +} + +async function dragTransfer(page: Page, files: DroppedFile[]) { + return await page.evaluateHandle( + (entries) => { + const dataTransfer = new DataTransfer(); + for (const entry of entries) { + dataTransfer.items.add( + new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }), + ); + } + return dataTransfer; + }, + files.map((file) => ({ ...file, bytes: [...file.bytes] })), + ); +} + +async function dragFilesOver(page: Page, files: DroppedFile[]): Promise { + const dataTransfer = await dragTransfer(page, files); + await terminalInput(page).dispatchEvent("dragenter", { dataTransfer }); + await terminalInput(page).dispatchEvent("dragover", { dataTransfer }); + await dataTransfer.dispose(); +} + +async function dropFiles(page: Page, files: DroppedFile[]): Promise { + const dataTransfer = await dragTransfer(page, files); + await terminalInput(page).dispatchEvent("drop", { dataTransfer }); + await dataTransfer.dispose(); +} + +/** The shared "Drop files here" overlay of the zone around the terminal. */ +function terminalDropOverlay(page: Page) { + return terminalSurface(page) + .locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]") + .getByTestId("file-drop-backdrop"); +} + +/** + * Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped + * files resolve to paths inside `directory` instead of uploading. + */ +async function typeLine(page: Page, text: string): Promise { + await terminalInput(page).pressSequentially(text); + await terminalInput(page).press("Enter"); +} + test.describe("Terminal protocol queries", () => { let harness: TerminalE2EHarness; test.beforeAll(async () => { harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" }); await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT); + await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT); }); test.afterAll(async () => { @@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => { await harness.killTerminal(terminalInstance.id); } }); + + async function openFileReceiver(page: Page, name: string) { + const terminal = await harness.createTerminal({ + name, + command: process.execPath, + args: ["file-receiver.cjs"], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY"); + return terminal; + } + + // Reads the PTY itself, so output from one pane cannot satisfy another's check. + async function terminalOutput(terminalId: string): Promise { + const capture = await harness.client.captureTerminal(terminalId); + return capture.lines.join("").replace(/\s/g, ""); + } + + async function expectTerminalOutput(terminalId: string, text: string): Promise { + await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text); + } + + test("pastes images from two panes into their own terminals as uploaded host files", async ({ + page, + }) => { + const secondPage = await page.context().newPage(); + const first = await openFileReceiver(page, "image-paste-a"); + const second = await openFileReceiver(secondPage, "image-paste-b"); + try { + await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]); + + await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload")); + await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload")); + expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload")); + expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload")); + } finally { + await secondPage.close(); + await harness.killTerminal(first.id); + await harness.killTerminal(second.id); + } + }); + + test("uploads dropped files of any type when the host is another machine", async ({ page }) => { + const terminal = await openFileReceiver(page, "file-drop-remote"); + const files = [ + { name: "shot.png", type: "image/png", bytes: pngBytes(3) }, + { name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") }, + ]; + try { + await dragFilesOver(page, files); + await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1"); + await expect(terminalDropOverlay(page)).toHaveText("Drop files here"); + + await dropFiles(page, files); + await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0"); + + await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload")); + await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("pastes dropped files' own paths when the host is this machine", async ({ page }) => { + const files = [ + { name: "local-shot.png", type: "image/png", bytes: pngBytes(9) }, + { name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") }, + ]; + for (const file of files) { + await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes); + } + await installLocalDesktopHost(page, harness.tempRepo.path); + const terminal = await openFileReceiver(page, "file-drop-local"); + try { + await dropFiles(page, files); + + await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local")); + await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local")); + expect(await terminalOutput(terminal.id)).not.toContain(":upload"); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("lands image pastes in gesture order while typing stays live", async ({ page }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-order"); + try { + await pasteImage(page, pngBytes(4)); + await upload.waitUntilHeld(); + await pasteImage(page, pngBytes(5)); + await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…"); + + await typeLine(page, "describe"); + await expectTerminalOutput(terminal.id, "TYPED:describe"); + + upload.release(); + await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload")); + const output = await terminalOutput(terminal.id); + expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0); + expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan( + output.indexOf(fileRead(pngBytes(5), "upload")), + ); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded"); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-cancel"); + try { + await pasteImage(page, pngBytes(6)); + await upload.waitUntilHeld(); + + await terminalInput(page).press("Control+c"); + await expectTerminalOutput(terminal.id, "INTERRUPTED"); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + upload.release(); + + await pasteImage(page, pngBytes(7)); + await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload")); + expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("a disconnect during upload reports one failure and input keeps working", async ({ + page, + }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-disconnect"); + try { + await pasteImage(page, pngBytes(8)); + await upload.waitUntilHeld(); + + await upload.disconnect(); + await expect(page.getByTestId("app-toast-message")).toHaveText( + "Failed to paste file: Host is not connected", + ); + + await typeLine(page, "after"); + await expectTerminalOutput(terminal.id, "TYPED:after"); + expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); }); diff --git a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts index c3481110f7..d0dbe9be23 100644 --- a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts +++ b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts @@ -1,5 +1,10 @@ import type { Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; +import { + expectTerminalCursor, + getTerminalBufferText, + waitForTerminalContent, +} from "../support/helpers/terminal-perf"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; interface AttachOverlayProbeWindow extends Window { @@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => { expect(await terminalAttachOverlayWasSeen(page)).toBe(false); }); + + for (const { count, retainedMarker } of [ + { count: 3000, retainedMarker: "history-0001界🙂" }, + { count: 12050, retainedMarker: "history-3000界🙂" }, + ]) { + test(`reattach restores retained styled Unicode history from ${count} lines`, async ({ + page, + }) => { + await page.addInitScript(() => { + const key = "@paseo:app-settings"; + const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 })); + }); + const terminal = await harness.createTerminal({ + name: `history-${count}`, + command: "/bin/sh", + args: [ + "-lc", + `i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`, + ], + }); + const last = `history-${String(count).padStart(4, "0")}界🙂`; + await harness.openTerminal(page, { terminalId: terminal.id }); + await waitForTerminalContent(page, (text) => text.includes(last), 10000); + expect(await getTerminalBufferText(page)).toContain(retainedMarker); + await page.reload(); + await waitForTerminalContent(page, (text) => text.includes(last), 10000); + const restored = await getTerminalBufferText(page); + expect(restored).toContain(retainedMarker); + expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual( + Math.min(count, 10000), + ); + if (count > 10000) expect(restored).not.toContain("history-0001界🙂"); + }); + } + for (const input of [ + { name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true }, + { + name: "program blinking underline", + presentation: "\x1b[3 q", + shape: "underline" as const, + blinking: true, + }, + ]) { + test(`reattach preserves the ${input.name} cursor`, async ({ page }) => { + const terminal = await harness.createTerminal({ + name: "cursor-presentation", + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`, + ], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000); + await expectTerminalCursor(page, input); + await page.reload(); + await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000); + await expectTerminalCursor(page, input); + }); + } }); diff --git a/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts index dcb538945d..0b3a068e63 100644 --- a/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts +++ b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts @@ -1,8 +1,15 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; +import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor"; +import { gateSecondToolCall } from "../support/helpers/tool-call-gate"; import { expectComposerVisible } from "../support/helpers/composer"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + readReactRenders, + recordReactRenders, + resetReactRenders, +} from "../support/helpers/react-renders"; type WebSocketMessage = string | Buffer; @@ -115,23 +122,27 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string }); }); + function appendCompletedCalls(extraCompletedCalls: number) { + if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) { + throw new Error("Expected the first completed tool call before release"); + } + for (let index = 0; index < extraCompletedCalls; index += 1) { + sendToPage?.( + cloneToolCallWithId( + firstCompletedMessage as WebSocketMessage, + `${firstCallId}:${sequenceOffset + index}`, + sequenceOffset + index + 1, + ), + ); + } + sequenceOffset += extraCompletedCalls; + } return { waitForFirstCompleted: () => firstCompleted, + appendCompletedCalls, release(extraCompletedCalls = 0) { isHolding = false; - if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) { - throw new Error("Expected the first completed tool call before release"); - } - for (let index = 0; index < extraCompletedCalls; index += 1) { - sendToPage?.( - cloneToolCallWithId( - firstCompletedMessage as WebSocketMessage, - `${firstCallId}:${index}`, - index + 1, - ), - ); - } - sequenceOffset = extraCompletedCalls; + appendCompletedCalls(extraCompletedCalls); for (const message of heldMessages.splice(0)) { sendToPage?.(shiftTimelineSequence(message, sequenceOffset)); } @@ -139,17 +150,24 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string }; } -async function configureOverviewToolCalls(page: Page): Promise { - await page.addInitScript(() => { +type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns"; + +// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone. +async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) { + await page.addInitScript((display) => { localStorage.setItem( "@paseo:app-settings", - JSON.stringify({ toolCallDetailLevel: "overview" }), + JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }), ); - }); + }, chatDisplay); } -async function createOverviewAgent(page: Page, title: string) { - await configureOverviewToolCalls(page); +async function createOverviewAgent( + page: Page, + title: string, + chatDisplay: OverviewChatDisplay = "collapseToolCalls", +) { + await configureOverviewToolCalls(page, chatDisplay); return seedMockAgentWorkspace({ repoPrefix: "tool-call-overview-sheet-", title, @@ -204,6 +222,36 @@ function readScrollMetrics(root: HTMLElement) { }; } +const FIRST_CALL_DETAILS = "export function ConversationList()"; + +function firstToolCall(page: Page): Locator { + return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first(); +} + +async function openToolDetails(badge: Locator): Promise { + await badge.getByRole("button").first().click(); +} + +async function timelineRowOf(locator: Locator) { + const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle(); + if (!row) throw new Error("Expected the locator inside a timeline row"); + return row; +} + +/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */ +async function isInsideRow( + locator: Locator, + row: Awaited>, +): Promise { + return locator.evaluate((node, previousRow) => previousRow.contains(node), row); +} + +async function topOf(row: Locator): Promise { + const box = await row.boundingBox(); + if (!box) throw new Error("Expected a visible timeline row"); + return box.y; +} + test.describe("compact overview tool calls", () => { test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true }); @@ -219,8 +267,16 @@ test.describe("compact overview tool calls", () => { await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet."); await gate.waitForFirstCompleted(); + const single = firstToolCall(page); + await expect(single).toBeVisible(); + await expect(page.getByTestId("tool-call-group")).toHaveCount(0); + await openToolDetails(single); + await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible(); + await page.getByTestId("tool-call-sheet-close").click(); + gate.appendCompletedCalls(1); const group = page.getByTestId("tool-call-group").first(); await expect(group).toBeVisible(); + await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden(); await group.click(); const sheet = page.getByTestId("tool-call-group-sheet"); @@ -261,19 +317,86 @@ test.describe("compact overview tool calls", () => { }); }); -test("keeps overview tool calls inline on desktop", async ({ page }) => { +for (const display of ["collapseToolCalls", "collapseTurns"] as const) { + test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({ + page, + }) => { + test.setTimeout(120_000); + const agent = await createOverviewAgent(page, "Desktop overview tool calls", display); + await recordReactRenders(page); + try { + const gate = await gateSecondToolCall(page, agent.agentId); + await openOverviewAgent(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls."); + await gate.waitForFirstCompleted(); + const lone = firstToolCall(page); + const group = page.getByTestId("tool-call-group").first(); + const details = page.getByText(FIRST_CALL_DETAILS); + + await test.step("a lone tool call shows as itself", async () => { + await expect(lone).toBeVisible(); + await expect(group).toHaveCount(0); + }); + + await gate.waitForSecondRunning(); + await test.step("opening it re-renders that tool call and nothing else", async () => { + await resetReactRenders(page); + await openToolDetails(lone); + await expect(details).toBeVisible(); + const renders = await readReactRenders(page); + expect(renders.AgentStreamView ?? 0).toBe(0); + expect(renders.StreamRowContent ?? 0).toBe(0); + expect(renders.ToolCall).toBe(1); + }); + + await test.step("the second call forms an open group around the opened call", async () => { + // Reading above the tail, the row holds still; at the tail the new rows push it up. + await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 }); + const loneTop = await topOf(lone); + const loneRow = await timelineRowOf(lone); + await resetReactRenders(page); + gate.releaseSecondRunning(); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(2); + await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible(); + expect(await isInsideRow(group, loneRow)).toBe(true); + expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2); + expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0); + await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0); + }); + + await test.step("closing and reopening the group keeps the call open", async () => { + await openToolDetails(group); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(0); + await expect(details).toHaveCount(0); + await openToolDetails(group); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(2); + await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible(); + }); + } finally { + await agent.cleanup(); + } + }); +} + +test("forms a closed group when the lone tool call was left closed", async ({ page }) => { test.setTimeout(120_000); - const agent = await createOverviewAgent(page, "Desktop overview tool calls"); - + const agent = await createOverviewAgent(page, "Closed desktop overview tool calls"); try { + const gate = await gateSecondToolCall(page, agent.agentId); await openOverviewAgent(page, agent); - await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls."); + await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed."); + await gate.waitForFirstCompleted(); + const lone = firstToolCall(page); const group = page.getByTestId("tool-call-group").first(); - await expect(group).toBeVisible(); + await expect(lone).toBeVisible(); + await expect(group).toHaveCount(0); - await group.click(); - await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0); - await expect(group.getByTestId("tool-call-badge").first()).toBeVisible(); + await gate.waitForSecondRunning(); + const loneTop = await topOf(lone); + gate.releaseSecondRunning(); + await expect(group).toBeVisible(); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(0); + expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/tool-call-shimmer.spec.ts b/packages/app/e2e/browser/tool-call-shimmer.spec.ts index 71ef6555e6..c37255d35e 100644 --- a/packages/app/e2e/browser/tool-call-shimmer.spec.ts +++ b/packages/app/e2e/browser/tool-call-shimmer.spec.ts @@ -1,11 +1,9 @@ -import type { Locator, Page } from "@playwright/test"; +import type { Locator } from "@playwright/test"; import { test, expect } from "../support/fixtures"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { gateSecondToolCall } from "../support/helpers/tool-call-gate"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -type WebSocketMessage = string | Buffer; - interface ShimmerEvidence { animationDuration: string; endPx: number; @@ -14,127 +12,6 @@ interface ShimmerEvidence { startPx: number; } -function parseSessionMessage(message: WebSocketMessage): Record | null { - const raw = typeof message === "string" ? message : message.toString("utf8"); - try { - const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; - return envelope.type === "session" && envelope.message && typeof envelope.message === "object" - ? (envelope.message as Record) - : null; - } catch { - return null; - } -} - -function getToolCallStatus( - message: WebSocketMessage, - agentId: string, -): { callId: string; status: string } | null { - const sessionMessage = parseSessionMessage(message); - if (sessionMessage?.type !== "agent_stream") { - return null; - } - const payload = sessionMessage.payload as Record | undefined; - if (payload?.agentId !== agentId) { - return null; - } - const event = payload.event as Record | undefined; - const item = event?.item as Record | undefined; - return event?.type === "timeline" && - item?.type === "tool_call" && - typeof item.callId === "string" && - typeof item.status === "string" - ? { callId: item.callId, status: item.status } - : null; -} - -function replaceToolCallStatus(message: WebSocketMessage, status: string): string { - const raw = typeof message === "string" ? message : message.toString("utf8"); - const envelope = JSON.parse(raw) as { - message?: { payload?: { event?: { item?: { status?: string } } } }; - }; - const item = envelope.message?.payload?.event?.item; - if (!item) { - throw new Error("Expected a tool-call session message"); - } - item.status = status; - return JSON.stringify(envelope); -} - -async function gateSecondToolCall(page: Page, agentId: string) { - let firstCallId: string | null = null; - let secondCallId: string | null = null; - let secondRunningMessage: WebSocketMessage | null = null; - let pauseAfterSecondRunning = false; - let releaseSecondRequested = false; - let forwardSecondRunning: (() => void) | null = null; - let resolveFirstCompleted!: () => void; - let resolveSecondRunning!: () => void; - const firstCompleted = new Promise((resolve) => { - resolveFirstCompleted = resolve; - }); - const secondRunning = new Promise((resolve) => { - resolveSecondRunning = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - forwardSecondRunning = () => { - if (secondRunningMessage) { - ws.send(secondRunningMessage); - secondRunningMessage = null; - } - }; - ws.onMessage((message) => server.send(message)); - server.onMessage((message) => { - if (pauseAfterSecondRunning) { - return; - } - - const toolCall = getToolCallStatus(message, agentId); - if (!toolCall) { - ws.send(message); - return; - } - - if (!firstCallId) { - firstCallId = toolCall.callId; - } - if (toolCall.callId === firstCallId) { - ws.send(message); - if (toolCall.status === "completed") { - resolveFirstCompleted(); - } - return; - } - - secondCallId ??= toolCall.callId; - if (toolCall.callId === secondCallId) { - const transformedMessage = - toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message; - secondRunningMessage = transformedMessage; - pauseAfterSecondRunning = true; - resolveSecondRunning(); - if (releaseSecondRequested) { - forwardSecondRunning?.(); - } - return; - } - - ws.send(message); - }); - }); - - return { - waitForFirstCompleted: () => firstCompleted, - waitForSecondRunning: () => secondRunning, - releaseSecondRunning() { - releaseSecondRequested = true; - forwardSecondRunning?.(); - }, - }; -} - async function readShimmerEvidence(locator: Locator): Promise { return locator.evaluate((root) => { const shimmer = Array.from(root.querySelectorAll("*")).find((element) => @@ -161,7 +38,7 @@ test("measures an overview heading that becomes loading after its idle mount", a await page.addInitScript(() => { localStorage.setItem( "@paseo:app-settings", - JSON.stringify({ toolCallDetailLevel: "overview" }), + JSON.stringify({ chatDisplay: "collapseToolCalls" }), ); }); const agent = await seedMockAgentWorkspace({ diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts new file mode 100644 index 0000000000..d990e33009 --- /dev/null +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -0,0 +1,613 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import path from "node:path"; +import { + holdOlderHistoryPages, + userScrollsTimelineToHistoryStart, +} from "../support/helpers/timeline-pagination"; +import { getActiveTabTestId } from "../support/helpers/launcher"; +import type { Page, Locator } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + providers: { + codex: { + enabled: true, + command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")], + }, + }, + }, + }, +}); + +type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns"; + +async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise { + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value })); + }, chatDisplay); +} + +async function seedAnsweredAgent(title: string) { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "turn-collapse-", + title, + model: "ten-second-stream", + initialPrompt: "Why does the chat stop following new output?", + }); + await agent.client.waitForFinish(agent.agentId, 30_000); + await agent.client.sendAgentMessage( + agent.agentId, + "Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40), + ); + await agent.client.waitForFinish(agent.agentId, 30_000); + return agent; +} + +/** Chat rows between the latest turn's summary line and its reply, in document order. */ +async function rowsBetweenLatestSummaryAndReply(page: Page): Promise { + return page.evaluate(() => { + const rows = [...document.querySelectorAll("[data-message-id]")]; + const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]")); + const replyRow = rows.findLastIndex((row) => + row.querySelector("[data-testid=assistant-message]"), + ); + return replyRow - summaryRow - 1; + }); +} + +async function groupedToolsInsideLatestTurn(page: Page): Promise { + return page.evaluate(() => { + const rows = [...document.querySelectorAll("[data-message-id]")]; + const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]")); + const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]")); + return rows + .slice(start + 1, end) + .flatMap((row) => + [...row.querySelectorAll("[data-testid=tool-call-group]")].map( + (group) => group.textContent ?? "", + ), + ); + }); +} + +async function expectCompactMessageToggle(prompt: Locator, label: string) { + const button = prompt.getByRole("button", { name: label, exact: true }); + const text = prompt.getByText(label, { exact: true }); + await expect + .poll( + async () => { + const control = (await button.boundingBox())!; + const glyphs = (await text.boundingBox())!; + return control.height - glyphs.height; + }, + { message: "The inline toggle adds no vertical space beyond its text" }, + ) + .toBe(0); +} + +async function expandAndCollapseLongPrompt(page: Page) { + const prompt = page.getByTestId("user-message").last(); + const before = await prompt.boundingBox(); + const text = prompt.locator('[data-message-text="true"]'); + const messageLeft = (await text.boundingBox())!.x; + await expect + .poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x) + .toBe(messageLeft); + await expectCompactMessageToggle(prompt, "Show more"); + await prompt.getByRole("button", { name: "Show more", exact: true }).click(); + await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible(); + await expect + .poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x) + .toBe(messageLeft); + await expect + .poll(async () => (await prompt.boundingBox())?.height ?? 0) + .toBeGreaterThan(before!.height); + await expectCompactMessageToggle(prompt, "Show less"); + await prompt.getByRole("button", { name: "Show less", exact: true }).click(); + await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height); + await prompt.scrollIntoViewIfNeeded(); + const bounds = (await prompt.boundingBox())!; + const body = (await text.boundingBox())!; + await page.screenshot({ + path: "/tmp/message-toggle-aligned.png", + clip: { + x: body.x - 16, + y: bounds.y, + width: bounds.x + bounds.width - body.x + 16, + height: bounds.height, + }, + }); +} + +test.describe("Turn collapse", () => { + test("folds each answered turn behind a summary line that opens it", async ({ page }) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedAnsweredAgent("Turn collapse"); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await expandAndCollapseLongPrompt(page); + + const summary = page.getByTestId("turn-summary").last().getByRole("button"); + await expect(summary).toHaveText(/^Edited 1 file/); + // Text chunks that stream back to back merge into one message, so the reply holds + // at least the stream's last chunk. + await expect(page.getByTestId("assistant-message").last()).toHaveText( + /\(end of synthetic stream\)$/, + ); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + + await summary.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0); + await expect(summary).toBeInViewport(); + await summary.evaluate((element) => element.scrollIntoView({ block: "start" })); + await page.screenshot({ path: "/tmp/turn-rail-aligned.png" }); + await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]); + + await summary.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + } finally { + await agent.cleanup(); + } + }); +}); + +test("received messages show their sender and open that agent", async ({ page }) => { + await page.addInitScript(() => + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })), + ); + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "sender-attribution-", + title: "Implementation", + }); + try { + const sender = await recipient.client.createAgent({ + provider: "codex", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title: "Reviewer", + model: "gpt-6.1-sol", + modeId: "full-access", + }); + await recipient.client.sendAgentMessage( + recipient.agentId, + "I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.", + { sourceAgentId: sender.id }, + ); + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true }); + await expect(attribution).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText( + "I reviewed the turn-grouping changes.", + ); + const labelColor = await attribution + .getByText("Sent by Reviewer", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor); + await expectSenderAlignedWithBubble(attribution); + await attribution.scrollIntoViewIfNeeded(); + await page.screenshot({ path: "/tmp/sender-attribution.png" }); + await attribution.click(); + await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id); + } finally { + await recipient.cleanup(); + } +}); + +test("long sender attribution stays on one line in compact chat", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.addInitScript(() => + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })), + ); + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "compact-attribution-", + title: "Implementation", + }); + try { + const title = "Reviewer checking the compact chat timeline and sender attribution"; + const sender = await recipient.client.createAgent({ + provider: "codex", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title, + model: "gpt-6.1-sol", + modeId: "full-access", + }); + await recipient.client.sendAgentMessage( + recipient.agentId, + "The sender label stays on one line, even when the agent has a long name.", + { sourceAgentId: sender.id }, + ); + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + const attribution = page.getByRole("button", { name: `Sent by ${title}`, exact: true }); + await expect(attribution).toBeVisible(); + await expectSenderTruncatedWithinMessage(attribution, title); + await expectSenderAlignedWithBubble(attribution); + await attribution.scrollIntoViewIfNeeded(); + await page.screenshot({ path: testInfo.outputPath("sender-compact-390.png") }); + await page.setViewportSize({ width: 320, height: 740 }); + await expectSenderTruncatedWithinMessage(attribution, title); + await expectSenderAlignedWithBubble(attribution); + await page.screenshot({ path: testInfo.outputPath("sender-compact-320.png") }); + } finally { + await recipient.cleanup(); + } +}); + +interface MessagePreviewWatch { + /** Frames after the watch started on which the fade or "Show more" control was missing. */ + framesWithoutClip: number[]; + /** Frames on which the fade was a different DOM node than when the watch started. */ + framesRemounted: number[]; +} + +declare global { + interface Window { + __paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch }; + } +} + +async function seedReceivedReviewWithToolTurn() { + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "preview-clip-", + title: "Implementation", + }); + const sender = await recipient.client.createAgent({ + provider: "mock", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title: "Reviewer", + modeId: "load-test", + model: "e2e-fast-stream", + }); + // Long enough to clip behind the preview fade; the instruction makes the recipient run tools. + await recipient.client.sendAgentMessage( + recipient.agentId, + "Review complete: the regression is red on the baseline and green with the fix.\n" + + "Keep the previous response visible while the reader scrolls.\n".repeat(30) + + "Now emit 7 tool agent stream updates", + { sourceAgentId: sender.id }, + ); + return recipient; +} + +/** Samples the received message's bubble on every animation frame until stopped. */ +async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise { + await page.evaluate((label) => { + const findBubble = () => + [...document.querySelectorAll("[data-testid=user-message]")].find((row) => + row.textContent?.includes(label), + ) ?? null; + const initialFade = findBubble()?.querySelector("svg") ?? null; + if (!initialFade) throw new Error(`No faded bubble for ${label}`); + const framesWithoutClip: number[] = []; + const framesRemounted: number[] = []; + let frame = 0; + let running = true; + const sample = () => { + if (!running) return; + const bubble = findBubble(); + const fade = bubble?.querySelector("svg") ?? null; + if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame); + else if (fade !== initialFade) framesRemounted.push(frame); + frame += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + window.__paseoMessagePreviewWatch = { + stop() { + running = false; + return { framesWithoutClip, framesRemounted }; + }, + }; + }, senderLabel); +} + +async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise { + // Give the opened turn's layout a few frames to settle before judging. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop()); + expect( + watch.framesWithoutClip, + "the preview fade and Show more control stay on every frame", + ).toEqual([]); + expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]); +} + +// Opening a turn must not rebuild its host row: the preview above the summary loses its +// measured height when remounted, which drops the fade and "Show more" for a frame. +test("opening a turn keeps the received message above it clipped on every frame", async ({ + page, +}) => { + await useChatDisplay(page, "collapseTurns"); + const recipient = await seedReceivedReviewWithToolTurn(); + try { + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + const bubble = page + .getByTestId("user-message") + .filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) }); + await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible(); + await bubble.scrollIntoViewIfNeeded(); + + await watchReceivedMessagePreview(page, "Sent by Reviewer"); + await page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 7 commands", exact: true }) + .click(); + await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible(); + await expectReceivedMessagePreviewUndisturbed(page); + } finally { + await recipient.cleanup(); + } +}); + +async function expectSenderTruncatedWithinMessage(attribution: Locator, title: string) { + const label = attribution.getByText(`Sent by ${title}`, { exact: true }); + await expect(label).toHaveCSS("white-space", "nowrap"); + await expect(label).toHaveCSS("text-overflow", "ellipsis"); + const bounds = await label.evaluate((element) => ({ + textWidth: element.scrollWidth, + visibleWidth: element.clientWidth, + right: element.getBoundingClientRect().right, + viewportWidth: document.documentElement.clientWidth, + })); + expect(bounds.textWidth).toBeGreaterThan(bounds.visibleWidth); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth); +} + +async function expectSenderAlignedWithBubble(attribution: Locator) { + const edges = await attribution.evaluate((button) => { + const bubble = button.nextElementSibling; + const label = button.querySelector('[dir="auto"]'); + if (!bubble || !label) throw new Error("Sender label or message bubble missing"); + return { + labelRight: label.getBoundingClientRect().right, + bubbleRight: bubble.getBoundingClientRect().right, + }; + }); + expect( + Math.abs(edges.labelRight - edges.bubbleRight), + "sender and bubble share the right edge", + ).toBeLessThanOrEqual(1); +} + +async function seedWholeTurnHistory() { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "whole-turn-history-", + title: "Whole turns", + model: "e2e-fast-stream", + }); + for (const prompt of [ + ...Array.from( + { length: 25 }, + (_, index) => `Short ${index}: emit 1 coalesced agent stream updates`, + ), + "Older long turn: emit 60 tool agent stream updates", + "Latest long turn: emit 120 tool agent stream updates", + ]) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + return agent; +} + +test("loads complete long turns and preserves expansion while revealing older history", async ({ + page, +}) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedWholeTurnHistory(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const latest = page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 120 commands", exact: true }); + await expect(latest).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + history.releasePage(1); + await expect( + page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 60 commands", exact: true }), + ).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + history.expectNoRepeatedEntries(); + await latest.scrollIntoViewIfNeeded(); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + } finally { + await agent.cleanup(); + } +}); + +async function useThinkingDisplay( + page: Page, + autoExpandReasoning: boolean, + chatDisplay: ChatDisplay, +) { + await page.addInitScript( + (settings) => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ + ...settings, + chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay, + }), + ); + }, + { autoExpandReasoning, chatDisplay }, + ); +} + +for (const autoExpandReasoning of [false, true]) { + test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "thinking-group-", + title: "Thinking with tools", + model: "ten-second-stream", + initialPrompt: "Inspect the scroll container", + }); + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const reasoning = page + .getByText(/Need to find the scroll container, the layout effect/) + .filter({ visible: true }) + .first(); + await expectThinkingPresentation(page, autoExpandReasoning); + await switchToCollapsedTurns(page); + await expectComposerVisible(page); + const summary = page.getByTestId("turn-summary").last().getByRole("button"); + await expect(summary).toBeVisible(); + await expect(summary).not.toContainText(/thought/i); + await expect(reasoning).toHaveCount(0); + await summary.click(); + await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0); + await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]); + await openOneToolOutput(page); + await summary.click(); + await expect(reasoning).toHaveCount(0); + } finally { + await agent.cleanup(); + } + }); +} + +async function openOneToolOutput(page: Page) { + const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true }); + await expect(output).toHaveCount(0); + const command = page + .getByTestId("tool-call-badge") + .filter({ hasText: "node scripts/simulate-stream-burst.mjs" }) + .first(); + await command.getByRole("button").first().click(); + await expect(output.first()).toBeVisible(); + await command.getByRole("button").first().click(); + await expect(output).toHaveCount(0); +} + +async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) { + const reasoning = page + .getByText(/Need to find the scroll container, the layout effect/) + .filter({ visible: true }) + .first(); + if (autoExpandReasoning) { + await expect(reasoning).toBeVisible(); + await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0); + } else { + await expect(reasoning).toHaveCount(0); + const group = page + .getByTestId("tool-call-group") + .filter({ hasText: /read 1 file/i, visible: true }) + .last(); + await expect(group).toContainText(/read 1 file/i); + await expect(group.getByRole("button").first()).not.toContainText(/thought/i); + await group.getByRole("button").first().click(); + // Expand thinking is off: the group opens its rows, but the thought row stays closed + // until the reader opens it. + const thought = group + .getByTestId("tool-call-badge") + .filter({ hasText: /^Thinking/ }) + .first(); + await expect(thought).toBeVisible(); + await expect(reasoning).toHaveCount(0); + // A press right after the group opens occasionally does not register; retry until the + // thought row opens. + await expect(async () => { + await thought.getByRole("button").first().click(); + await expect(reasoning).toBeVisible({ timeout: 2_000 }); + }).toPass(); + } +} + +async function switchToCollapsedTurns(page: Page) { + await page.evaluate(() => { + sessionStorage.setItem("e2e-chat-display", "collapseTurns"); + }); + await page.reload(); +} + +test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({ + page, +}) => { + test.setTimeout(90_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "heartbeat-turn-", + title: "Heartbeat turns", + model: "ten-second-stream", + initialPrompt: "Inspect the scroll container", + }); + const schedules = agent.client as unknown as Pick< + DaemonClient, + "scheduleCreate" | "scheduleRunOnce" | "scheduleDelete" + >; + let scheduleId: string | undefined; + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const summaries = page.getByTestId("turn-summary"); + await expect(summaries).toHaveCount(1); + const previousSummary = await summaries.first().innerText(); + const previousReplyId = await page + .getByTestId("assistant-message") + .last() + .evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id")); + const created = await schedules.scheduleCreate({ + name: "Check the completed work", + prompt: "Inspect the scroll container again", + cadence: { type: "cron", expression: "0 0 1 1 *" }, + target: { type: "self", agentId: agent.agentId }, + runOnCreate: false, + }); + expect(created.error).toBeNull(); + scheduleId = created.schedule!.id; + const fired = await schedules.scheduleRunOnce({ id: scheduleId }); + expect(fired.error).toBeNull(); + await expect( + page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }), + ).toBeVisible(); + await expect(summaries.first()).toHaveText(previousSummary); + const previousReply = page + .locator(`[data-message-id="${previousReplyId}"]`) + .getByTestId("assistant-message"); + await expect(previousReply).toBeVisible(); + await expect(previousReply).toContainText("(end of synthetic stream)"); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expect(summaries).toHaveCount(2); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2); + await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" }); + } finally { + if (scheduleId) await schedules.scheduleDelete({ id: scheduleId }); + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index a923971d09..6835e01bcb 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -4,9 +4,10 @@ import path from "node:path"; import type { Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; import { addConnectedHostAndReload } from "../support/helpers/hosts"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { openSettingsSection } from "../support/helpers/settings"; import { getServerId } from "../support/helpers/server-id"; import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { @@ -16,6 +17,7 @@ import { } from "../support/helpers/usage-reports"; import { installLoginUsage, + expectLoginUsageFreshness, installCodexWindowUsage, expectCodexReportedWindows, refreshLoginUsage, @@ -130,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt await refreshAllUsage(page); await usage.waitForListRequests(requestCount + 1); const group = page.getByTestId(`usage-host-${getServerId()}`); - await expect(group.getByText("41%", { exact: true })).toBeVisible(); + await expect(group.getByText("41% used", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled(); } @@ -177,7 +179,7 @@ test.describe("usage modal", () => { await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(hostFilter(page)).toHaveCount(0); await qaScreenshot(page, "usage-modal-one-host"); - await expect(group.getByText("31%")).toBeVisible(); + await expect(group.getByText("31% used")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -239,15 +241,15 @@ test.describe("usage modal", () => { await gotoAppShell(page); await openUsageFromItem(page); const group = page.getByTestId(`usage-host-${serverId}`); - await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); + await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 }); const alphaRefresh = group.getByTestId("usage-refresh").first(); await alphaRefresh.click(); await expect .poll(() => forcedRefreshes(usage)) .toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]); - await expect(group.getByText("58%")).toBeVisible(); - await expect(group.getByText("12%")).toBeVisible(); + await expect(group.getByText("58% used")).toBeVisible(); + await expect(group.getByText("12% used")).toBeVisible(); await group.getByTestId("usage-refresh").nth(1).hover(); await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); @@ -331,7 +333,7 @@ test.describe("usage modal", () => { await hostFilter(page).click(); await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click(); await expect( - page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"), ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); await qaScreenshot(page, "usage-modal-picked-host"); @@ -346,7 +348,7 @@ test.describe("usage modal", () => { await expectPinnedUsage(page, ["12% Weekly"]); await openUsageFromItem(page); await expect( - page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"), ).toBeVisible({ timeout: 30_000 }); } finally { await secondary.close().catch(() => undefined); @@ -512,7 +514,7 @@ for (const theme of ["light", "dark"] as const) { await expect(screen.getByText("69% left")).toBeVisible(); await showUsageAs(page, "used"); await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( - /^Pin Claude Session, 31% · resets /, + /^Pin Claude Session, 31% used · resets /, ); await openUsageOptions(page); await closeUsageOptions(page); @@ -575,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl }, ]); await refreshLoginUsage(page); - await expectLoginUsageOnly(page, "42%"); + await expectLoginUsageOnly(page, "42% used"); } finally { await fixture.cleanup(); } @@ -588,6 +590,156 @@ async function expectLoginErrors(page: Page, lines: string[]) { async function expectLoginUsageOnly(page: Page, percentage: string) { const card = page.getByTestId("usage-report-login-journey:account"); - await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible(); await expect(card.getByTestId("usage-login-error")).toHaveCount(0); } + +for (const compact of [false, true]) { + test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({ + page, + }) => { + const fixture = await installLoginUsage({ status: "error", error: "Initial failure" }); + try { + await page.clock.install(); + await page.setViewportSize( + compact ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + await gotoAppShell(page); + if (compact) await openCompactSidebar(page); + await openUsageFromIcon(page); + await expectLoginErrors(page, ["Claude: Initial failure"]); + await test.step("developer errors do not interrupt freshness inspection", async () => { + await page.evaluate(() => console.error("E2E: developer toast while Usage is open")); + await expectLoginUsageFreshness(page, "No successful update", compact); + }); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }], + }); + // Move only the browser clock; real daemon fetches continue using the host clock. + await page.clock.setFixedTime(new Date(Date.now() + 3_900_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42% used"); + await advanceUsageClock(page, 60_000); + await expectLoginUsageFreshness(page, "Updated 1h ago", compact); + + await fixture.setReport({ status: "error", error: "Rate limited" }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "unavailable", + problem: { kind: "no_quota", detail: "No active quota" }, + }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: No active quota"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }], + }); + await page.clock.setFixedTime(new Date(Date.now() + 150_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "43% used"); + await advanceUsageClock(page, 60_000); + await expect(page.getByText("42% used", { exact: true })).toHaveCount(0); + await expectLoginUsageFreshness(page, "Updated 2m ago", compact); + } finally { + await fixture.cleanup(); + } + }); +} + +async function expectRetainedLoginUsage(page: Page, message: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveText(message); + await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible(); +} + +async function switchToJapanese(page: Page) { + await page.getByRole("button", { name: "Close", exact: true }).click(); + await openSettings(page); + await openSettingsSection(page, "general"); + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click(); + await page.getByRole("button", { name: "戻る", exact: true }).click(); +} + +async function openJapaneseUsage(page: Page) { + await page.getByRole("button", { name: "使用状況", exact: true }).click(); + await expect(page.getByTestId("usage-modal").first()).toBeVisible(); +} + +test("usage percentages state their meaning and follow the selected language", async ({ page }) => { + test.setTimeout(120_000); + const login = await installLoginUsage({ + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }], + }); + try { + await gotoAppShell(page); + await openUsageFromIcon(page); + await expect(page.getByText("70% used", { exact: true })).toBeVisible(); + await switchToJapanese(page); + await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/); + await openJapaneseUsage(page); + await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "設定", exact: true }).last().click(); + await expect(page.getByText("割合", { exact: true })).toBeVisible(); + await expect(page.getByText("使用済み", { exact: true })).toBeVisible(); + await expect(page.getByText("残り", { exact: true })).toBeVisible(); + } finally { + await login.cleanup(); + } +}); + +test("open usage clocks advance without refreshing or reopening", async ({ page }) => { + test.setTimeout(120_000); + const now = new Date(); + const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString(); + const login = await installLoginUsage({ + status: "available", + windows: [ + { id: "session", label: "Session", usedPct: 42, resetsAt: reset }, + { id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 }, + ], + balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }], + }); + try { + await openCompactUsageWithClock(page, now); + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now"); + await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible(); + // The shared minute clock is wall-aligned, and the daemon timestamps use real time. + // After two browser minutes the latest displayed tick is one or two minutes old. + await advanceUsageClock(page, 120_000); + await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/); + await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible(); + await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible(); + await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText( + /^resets in (28|29)m\s·\s/, + ); + } finally { + await login.cleanup(); + } +}); + +async function openCompactUsageWithClock(page: Page, now: Date) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.clock.install({ time: now }); + await gotoAppShell(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); +} + +async function advanceUsageClock(page: Page, milliseconds: number) { + await page.clock.fastForward(milliseconds); +} diff --git a/packages/app/e2e/browser/usage-off.spec.ts b/packages/app/e2e/browser/usage-off.spec.ts new file mode 100644 index 0000000000..a0114c8e33 --- /dev/null +++ b/packages/app/e2e/browser/usage-off.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + claudeAndCodexReports, + openUsageFromIcon, + openUsageOptions, + closeUsageOptions, + closeUsage, + expectNoUsageItem, + expectPinnedUsage, +} from "../support/helpers/usage-sidebar-item"; + +test("Check usage applies to the host and Turn on restores cards", async ({ + page, + e2eWorkerClient, +}) => { + test.setTimeout(180_000); + const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true }; + try { + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + await openUsageOptions(page); + await page.getByRole("switch", { name: "Check usage", exact: true }).click(); + await closeUsageOptions(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false }); + await expectNoUsageItem(page); + const before = usage.listRequests().length; + await closeUsage(page); + await page.reload(); + await openUsageFromIcon(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + expect(usage.listRequests()).toHaveLength(before); + await page.getByRole("button", { name: "Turn on", exact: true }).click(); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true }); + // A separate client changes the policy, as another device would. + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } }); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } }); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + } finally { + await e2eWorkerClient.patchDaemonConfig({ usage: previous }); + } +}); + +test("older hosts keep usage without a Check usage switch", async ({ page }) => { + test.setTimeout(180_000); + await installUsageReportsFixture(page, { + usageSettingSupported: false, + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await openUsageFromIcon(page); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + await openUsageOptions(page); + await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0); +}); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 1b509f5640..15dd2fdb7a 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -93,7 +93,7 @@ test.describe("usage settings", () => { await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible(); await expect(card.getByText("Beta plan", { exact: true })).toBeVisible(); - await expect(card.getByText("70%")).toBeVisible(); + await expect(card.getByText("70% used")).toBeVisible(); await expect(card.getByText("1,234 left", { exact: true })).toBeVisible(); await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); @@ -131,11 +131,11 @@ test.describe("usage settings", () => { await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); const card = page.getByTestId("usage-card"); - await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 }); + await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 }); await refreshAllUsage(page); await expect.poll(() => forcedRefreshCount(usage)).toBe(1); - await expect(card.getByText("64%")).toBeVisible(); + await expect(card.getByText("64% used")).toBeVisible(); }); test("asks to update a host without usage support and never calls it", async ({ page }) => { diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 27fac6691c..9151403904 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async ( ).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-usage-modal"); }); + +test("hidden usage sends no requests until opened", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectNoUsageItem(page); + await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({ + timeout: 90_000, + }); + await openSidebarNavSettings(page); + await expectFooterItemSetting(page, "usage", false); + await leaveSettings(page); + expect(usage.listRequests()).toHaveLength(0); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + expect(usage.listRequests()).toHaveLength(1); +}); + +test("visible usage summary requests reports on load", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + expect(usage.listRequests()).toHaveLength(1); +}); diff --git a/packages/app/e2e/browser/voice-settings.spec.ts b/packages/app/e2e/browser/voice-settings.spec.ts new file mode 100644 index 0000000000..3949486f15 --- /dev/null +++ b/packages/app/e2e/browser/voice-settings.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "../support/fixtures"; +import { + apiKeyInput, + baseUrlInput, + expectSheetClosed, + expectSpeechSiteSummary, + openSpeechSite, + openVoiceSettings, + providerOption, + readSpeechSite, + resetSpeechSite, + saveSpeechSite, + withUnwritableDaemonConfig, +} from "../support/helpers/voice-settings"; + +// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model +// download. That makes the provider picker read-only here; the env key covers the key fallback. +const ENV_API_KEY = "sk-e2e-env-fallback"; +const SAVED_API_KEY = "sk-e2e-saved-key"; +const BASE_URL = "http://127.0.0.1:9/v1"; + +test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } }); + +test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({ + page, +}) => { + try { + await openVoiceSettings(page); + await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1"); + + const sheet = await test.step("the env var decides the provider", async () => { + const opened = await openSpeechSite(page, "Listening"); + await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible(); + await expect(providerOption(opened, "Local")).toBeDisabled(); + await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true"); + await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible(); + return opened; + }); + + await test.step("a saved key and base URL win over the env key", async () => { + await apiKeyInput(sheet).fill(SAVED_API_KEY); + await baseUrlInput(sheet).fill(BASE_URL); + await saveSpeechSite(sheet); + await expectSheetClosed(page); + await expect + .poll(() => readSpeechSite("voiceMode.stt")) + .toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL }); + }); + + await test.step("reopening shows the key as saved without revealing it", async () => { + const reopened = await openSpeechSite(page, "Listening"); + await expect(apiKeyInput(reopened)).toHaveValue(""); + await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved"); + await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0); + await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL); + await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + }); + + await test.step("clearing the base URL keeps the saved key", async () => { + const sheetAgain = page.getByRole("dialog"); + await baseUrlInput(sheetAgain).fill(""); + await saveSpeechSite(sheetAgain); + await expectSheetClosed(page); + const site = await readSpeechSite("voiceMode.stt"); + expect(site).toMatchObject({ provider: "openai", hasApiKey: true }); + expect(site).not.toHaveProperty("baseUrl"); + }); + } finally { + await resetSpeechSite("voiceMode.stt"); + } +}); + +test("a failed save keeps the sheet open with the user's input", async ({ page }) => { + try { + await openVoiceSettings(page); + const sheet = await openSpeechSite(page, "Speaking"); + await baseUrlInput(sheet).fill(BASE_URL); + + await withUnwritableDaemonConfig(async () => { + await saveSpeechSite(sheet); + await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible(); + }); + + await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL); + await saveSpeechSite(sheet); + await expectSheetClosed(page); + await expect + .poll(() => readSpeechSite("voiceMode.tts")) + .toMatchObject({ provider: "openai", baseUrl: BASE_URL }); + } finally { + await resetSpeechSite("voiceMode.tts"); + } +}); diff --git a/packages/app/e2e/browser/workspace-cwd.spec.ts b/packages/app/e2e/browser/workspace-cwd.spec.ts deleted file mode 100644 index e702cc0f7d..0000000000 --- a/packages/app/e2e/browser/workspace-cwd.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, test } from "../support/fixtures"; -import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; -import { - expectTerminalSurfaceVisible, - focusTerminalSurface, - typeInTerminal, - setupDeterministicPrompt, - waitForTerminalContent, -} from "../support/helpers/terminal-perf"; - -interface CreatedAgentCwdAssertion { - workspaceId: string; - cwd: string | null; -} - -async function fetchSingleAgentForWorkspace( - workspace: SeededWorkspace, -): Promise { - const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries - .map((entry) => entry.agent) - .filter((agent) => agent.workspaceId === workspace.workspaceId); - if (agents.length !== 1) { - return null; - } - const [agent] = agents; - return { - workspaceId: workspace.workspaceId, - cwd: agent.cwd, - }; -} - -test.describe("Workspace cwd correctness", () => { - test("main checkout workspace opens terminals in the project root", async ({ - page, - withWorkspace, - }) => { - test.setTimeout(60_000); - - const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" }); - await workspace.navigateTo(); - await clickNewTerminal(page); - - await expectTerminalSurfaceVisible(page); - await focusTerminalSurface(page); - await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); - await typeInTerminal(page, "pwd\n"); - await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); - }); - - test("draft tab creates an agent in the workspace cwd", async ({ page }) => { - test.setTimeout(60_000); - - const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" }); - try { - await gotoWorkspace(page, workspace.workspaceId); - - await clickNewChat(page); - const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); - const message = `cwd draft create ${Date.now()}`; - await expect(composer).toBeEditable({ timeout: 15_000 }); - await composer.fill(message); - await composer.press("Enter"); - await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ - timeout: 30_000, - }); - - await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({ - timeout: 30_000, - }); - - await expect - .poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 }) - .toEqual({ - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - }); - } finally { - await workspace.cleanup(); - } - }); - - test("worktree workspace opens terminals in the worktree directory", async ({ - page, - withWorkspace, - }) => { - test.setTimeout(90_000); - - const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" }); - await workspace.navigateTo(); - await clickNewTerminal(page); - - await expectTerminalSurfaceVisible(page); - await focusTerminalSurface(page); - await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); - await typeInTerminal(page, "pwd\n"); - await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); - }); -}); diff --git a/packages/app/e2e/browser/workspace-lifecycle.spec.ts b/packages/app/e2e/browser/workspace-lifecycle.spec.ts index 7c7a20658f..83659cd001 100644 --- a/packages/app/e2e/browser/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/browser/workspace-lifecycle.spec.ts @@ -3,6 +3,7 @@ import { createAgentChatFromLauncher, createStandaloneTerminalFromLauncher, expectTerminalCwd, + submitAgentChatAndExpectCwd, } from "../support/helpers/workspace-lifecycle"; test.describe("Workspace lifecycle", () => { @@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => { const workspace = await withWorkspace({ prefix: "lifecycle-main-" }); await workspace.navigateTo(); - await test.step("creates an agent chat", async () => { + await test.step("submitting a draft creates an agent in the project root", async () => { await createAgentChatFromLauncher(page); + await submitAgentChatAndExpectCwd(page, workspace); }); await test.step("creates a terminal in the project root", async () => { diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts index e1dd703508..976e745549 100644 --- a/packages/app/e2e/browser/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses( } test.describe("Workspace model restart regressions", () => { - test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({ + test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({ page, baseURL, }) => { @@ -423,9 +423,13 @@ test.describe("Workspace model restart regressions", () => { .toMatchObject({ id: LEGACY_AGENT_ID, workspaceId: seeded.workspaceA, - status: "running", + status: "closed", }); + await expect + .poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB])) + .toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" }); + await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA)); await waitForSidebarHydration(page); await expectWorkspaceRowDoesNotShowIndicator(page, { @@ -472,15 +476,11 @@ test.describe("Workspace model restart regressions", () => { ]), ) .toMatchObject({ + [seeded.workspaceA]: "done", [seeded.workspaceB]: "done", [createdWorkspaceId]: "done", }); - // The restarted provider session may settle while the browser creates the sibling. Its - // initial running status is asserted above; this phase verifies that ownership never moves. - const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]); - expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]); - await expectWorkspaceRowDoesNotShowIndicator(page, { serverId, workspaceId: seeded.workspaceB, diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index fdc0529451..a902fde117 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -11,6 +11,10 @@ import { expectWorkspaceTabVisible, openWorkspaceWithAgents, } from "../support/helpers/archive-tab"; +import { + expectAgentAbsentFromWorkspaceLayout, + seedForeignPinnedAgentTab, +} from "../support/helpers/workspace-layout"; import { clickNewChat } from "../support/helpers/launcher"; import { expectComposerVisible } from "../support/helpers/composer"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -93,10 +97,97 @@ async function closeFirstVisibleDraftTab(page: Page): Promise { await closeButton.first().click(); } +async function openWorkspaceTabUrl(page: Page, route: string): Promise { + await page.evaluate((url) => { + window.history.pushState(null, "", url); + window.dispatchEvent(new PopStateEvent("popstate")); + }, route); + await expectAppRoute(page, route.split("?")[0]); +} + test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); - test("opens a notification's workspace on a different offline host", async ({ page }) => { + test("removes a foreign pinned agent when restoring a saved workspace layout", async ({ + page, + }, testInfo) => { + const serverId = getServerId(); + const firstWorkspace = await seedWorkspace({ + repoPrefix: "workspace-owner-", + title: "Original workspace", + }); + const secondWorkspace = await seedWorkspace({ + repoPrefix: "workspace-restored-", + title: "Restored workspace", + }); + try { + const firstAgent = await createMockIdleAgent(firstWorkspace.client, { + cwd: firstWorkspace.repoPath, + workspaceId: firstWorkspace.workspaceId, + title: "Original workspace agent", + }); + const secondAgent = await createMockIdleAgent(secondWorkspace.client, { + cwd: secondWorkspace.repoPath, + workspaceId: secondWorkspace.workspaceId, + title: "Restored workspace agent", + }); + await gotoAppShell(page); + await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + await seedForeignPinnedAgentTab(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + + await page.reload(); + await waitForSidebarHydration(page); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: secondWorkspace.projectDisplayName, + }); + await testInfo.attach("hydrated-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }), + contentType: "image/png", + }); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); + await testInfo.attach("restored-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }), + contentType: "image/png", + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: firstWorkspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]); + const agents = await firstWorkspace.client.fetchAgents(); + expect( + agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId, + ).toBe(firstWorkspace.workspaceId); + expect( + agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId, + ).toBe(secondWorkspace.workspaceId); + await testInfo.attach("original-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }), + contentType: "image/png", + }); + } finally { + await secondWorkspace.cleanup(); + await firstWorkspace.cleanup(); + } + }); + + test("keeps an offline notification on the agent route until the host connects", async ({ + page, + }) => { const target = { serverId: "notification-offline-host", workspaceId: "notification-workspace", @@ -123,14 +214,33 @@ test.describe("Workspace navigation regression", () => { ); }, target); - await expectAppRoute( - page, - buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`), - { timeout: 30_000 }, - ); - await expect(page.getByText("Connecting", { exact: true })).toBeVisible(); - await expect(page.getByText("Notification Host", { exact: true })).toBeVisible(); - await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0); + await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), { + timeout: 30_000, + }); + await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible(); + await expect(page.getByText(/Notification Host/).first()).toBeVisible(); + }); + + test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" }); + try { + const agent = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Cold notification agent", + }); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoAppShell(page); + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id)); + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), { + timeout: 30_000, + }); + await expectComposerVisible(page); + } finally { + await workspace.cleanup(); + } }); test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({ @@ -321,7 +431,7 @@ test.describe("Workspace navigation regression", () => { } }); - test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ + test("a stale notification workspace preserves retained workspace selection and tabs", async ({ page, }) => { const serverId = getServerId(); @@ -435,7 +545,7 @@ test.describe("Workspace navigation regression", () => { }), ); }, - { agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId }, + { agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId }, ); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { @@ -463,6 +573,22 @@ test.describe("Workspace navigation regression", () => { await expect(secondDeckEntry).toBeHidden(); await expectWorkspaceDeckEntryCount(page, 2); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: firstWorkspace.workspaceId, + agentId: secondAgent.id, + }); + await openWorkspaceTabUrl( + page, + buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`), + ); + await expectWorkspaceTabVisible(page, firstAgent.id); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + await page.reload(); await waitForSidebarHydration(page); await waitForWorkspaceTabsVisible(page); diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts index 4853e9daa4..5066411c78 100644 --- a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar"; import { expectSidebarWorkspaceSelected, switchWorkspaceViaSidebar, @@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s } await page.setViewportSize({ width: 1440, height: 1100 }); await gotoAppShell(page); + await revealSidebarProjectWorkspaces(page, project.projectKey); const order = await readVisibleWorkspaceOrder(page); await test.step("next crosses the ninth workspace and previous returns to it", async () => { @@ -68,7 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s workspaceId: order[8], }); }); + await test.step("collapsed groups keep shortcut targets selected without expanding", async () => { + await expectCollapsedWorkspaceShortcuts(page, order); + }); } finally { await project.cleanup(); } }); + +async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) { + await page.getByLabel("Show less", { exact: true }).click(); + const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); + await expect(rows).toHaveCount(8); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] }); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await expect(rows).toHaveCount(7); + await page.keyboard.press("Alt+9"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`)); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] }); + await expect(rows).toHaveCount(8); + await page.keyboard.press("Alt+]"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`)); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] }); + await expect(rows).toHaveCount(8); + await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount( + 0, + ); + await expect(page.getByLabel("Show more", { exact: true })).toBeVisible(); +} diff --git a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts index 17c4d3dd45..969f330520 100644 --- a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts +++ b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures"; import { createTempGitRepo } from "../support/helpers/workspace"; import { connectWorkspaceSetupClient, + setupCommandOutput, createWorkspaceThroughDaemon, openWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, @@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page await seedProjectForWorkspaceSetup(client, repo.path); const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 7fe8ce1814..18a9704ddf 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -18,6 +18,7 @@ import { } from "../support/helpers/terminal-perf"; import { connectWorkspaceSetupClient, + setupCommandOutput, createWorkspaceThroughDaemon, expectSetupPanel, openHomeWithProject, @@ -28,6 +29,7 @@ import { closeWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, waitForWorkspaceSetupProgress, + observeSetupPanelTraffic, } from "../support/helpers/workspace-setup"; interface WorkspaceScriptStarter { @@ -91,7 +93,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -116,6 +119,45 @@ test.describe("Workspace setup streaming", () => { } }); + test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-membership-", { + paseoConfig: { worktree: { setup: ["echo setup complete"] } }, + }); + const traffic = observeSetupPanelTraffic(page); + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed", + ); + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "setup-membership", + }); + await completed; + await openHomeWithProject(page, repo.path); + await navigateToWorkspaceViaSidebar(page, workspace.id); + await expectSetupTabNotSeeded(page, workspace.id); + await traffic.expectNoDemand(); + await expectSetupPanel(page); + await traffic.expectSubscribed(workspace.id, 1); + await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete"); + await clickNewChat(page); + await expectComposerVisible(page); + await traffic.expectReleased(0); + await expectSetupPanel(page); + await traffic.expectSubscribed(workspace.id, 2); + await closeSetupTab(page, workspace.id); + await traffic.expectReleased(1); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + daemonTest("streams running and completed setup snapshots for a successful setup", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-success-", { @@ -130,16 +172,19 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const initialRunning = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "running" && payload.detail.log === "", + (payload) => payload.status === "running" && payload.detail.commands.length === 0, ); const runningWithOutput = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "running" && payload.detail.log.includes("starting setup"), + (payload) => + payload.status === "running" && + setupCommandOutput(payload.detail).includes("starting setup"), ); const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); await createWorkspaceThroughDaemon(client, { @@ -152,8 +197,8 @@ test.describe("Workspace setup streaming", () => { const completedPayload = await completed; expect(initialPayload.detail.log).toBe(""); - expect(runningPayload.detail.log).toContain("starting setup"); - expect(completedPayload.detail.log).toContain("setup complete"); + expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup"); + expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete"); expect(completedPayload.error).toBeNull(); } finally { await client.close(); @@ -175,7 +220,9 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const failed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"), + (payload) => + payload.status === "failed" && + setupCommandOutput(payload.detail).includes("setup failed"), ); const workspace = await createWorkspaceThroughDaemon(client, { @@ -184,8 +231,8 @@ test.describe("Workspace setup streaming", () => { }); const failedPayload = await failed; - expect(failedPayload.detail.log).toContain("starting setup"); - expect(failedPayload.detail.log).toContain("setup failed"); + expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup"); + expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed"); expect(failedPayload.error).toMatch(/failed/i); await openHomeWithProject(page, repo.path); @@ -291,7 +338,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -337,7 +385,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const result = await client.createPaseoWorktree({ diff --git a/packages/app/e2e/fixtures/fake-pi-rpc.mjs b/packages/app/e2e/fixtures/fake-pi-rpc.mjs index b6d156ddfc..372e29c44b 100644 --- a/packages/app/e2e/fixtures/fake-pi-rpc.mjs +++ b/packages/app/e2e/fixtures/fake-pi-rpc.mjs @@ -6,6 +6,7 @@ for await (const line of lines) { const command = JSON.parse(line); let data = {}; if (command.type === "get_available_models") { + if (process.argv.includes("--stall-model-discovery")) continue; data = { models: [ { diff --git a/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad b/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad index 9f758a2806..279009d380 100644 --- a/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad +++ b/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad @@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug open "${APP_ID}" wait "id=\"workspace-header-menu-trigger\"" 20000 press "id=\"workspace-explorer-toggle\"" -wait "id=\"explorer-tab-files\"" 10000 -press "id=\"explorer-tab-files\"" +wait "id=\"workspace-tab-files\"" 10000 +press "id=\"workspace-tab-files\"" wait "id=\"file-explorer-row-0\"" 10000 longpress "id=\"file-explorer-row-0\"" 600 wait "id=\"file-explorer-row-0-context-menu-content\"" 10000 diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad index c94e24914d..359e89c5c9 100644 --- a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad @@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0 env APP_ID=sh.paseo.debug # Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible, -# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms). +# unselected PINNED workspace row. Ordinary workspace rows are not draggable. +# Hold past drag arming (180ms), then release. open "${APP_ID}" press "label=\"Open menu\"" wait "id=\"${WORKSPACE_ROW}\"" 10000 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad b/packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad new file mode 100644 index 0000000000..ac029c3cb6 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad @@ -0,0 +1,19 @@ +# Prerequisite: a native terminal showing the output of echo '한글 문장' +# on its first row, with no active selection. Use a dedicated QA workspace. +# Coordinates target the first half of 문 on a 1080x1920 phone at default +# code size. Override WORD_X/WORD_Y for other device geometry. +context platform=android timeout=30000 retries=0 +env APP_ID=sh.paseo.debug +env WORD_X=105 +env WORD_Y=251 + +open "${APP_ID}" +wait "label=\"Terminal input\"" 10000 +wait "label=\"한글 문장\"" 10000 +longpress "${WORD_X}" "${WORD_Y}" 650 +wait "id=\"terminal-copy\"" 5000 +press "id=\"terminal-copy\"" +# Copy clears the selection; only Android's clipboard preview has this exact +# text, since the unselected terminal row contains the full sentence. +wait "text=\"문장\"" 3000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-user-message-preview-preconditions.md b/packages/app/e2e/mobile/agent-device/native-user-message-preview-preconditions.md new file mode 100644 index 0000000000..9985a3f544 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-user-message-preview-preconditions.md @@ -0,0 +1,14 @@ +# Native user message preview + +Open a chat with a user message containing 16 newline-separated lines of +`Preview line N`, with N from 1 to 16. Scroll the message into view on a +phone-sized Android emulator or iOS simulator. Its text must exceed the 240-point preview cap, +while its expanded bubble and toggle fit in the chat viewport. Close any other +expanded messages so the toggle labels identify this message. + +Replay `native-user-message-preview.android.ad` or +`native-user-message-preview.ios.ad` through the existing mobile runner or +agent-device. It checks that a clipped message offers Show more and +that two expand/collapse round trips keep both controls reachable. Before the +fix, the first Show more assertion fails because native layout reports the +capped height as the full content height. diff --git a/packages/app/e2e/mobile/agent-device/native-user-message-preview.android.ad b/packages/app/e2e/mobile/agent-device/native-user-message-preview.android.ad new file mode 100644 index 0000000000..45630217bb --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-user-message-preview.android.ad @@ -0,0 +1,14 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "role=\"button\" label=\"Show more\"" 10000 +press "role=\"button\" label=\"Show more\"" +wait "role=\"button\" label=\"Show less\"" 10000 +press "role=\"button\" label=\"Show less\"" +wait "role=\"button\" label=\"Show more\"" 10000 +press "role=\"button\" label=\"Show more\"" +wait "role=\"button\" label=\"Show less\"" 10000 +press "role=\"button\" label=\"Show less\"" +wait "role=\"button\" label=\"Show more\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-user-message-preview.ios.ad b/packages/app/e2e/mobile/agent-device/native-user-message-preview.ios.ad new file mode 100644 index 0000000000..d19d7dd8aa --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-user-message-preview.ios.ad @@ -0,0 +1,14 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "role=\"button\" label=\"Show more\"" 10000 +press "role=\"button\" label=\"Show more\"" +wait "role=\"button\" label=\"Show less\"" 10000 +press "role=\"button\" label=\"Show less\"" +wait "role=\"button\" label=\"Show more\"" 10000 +press "role=\"button\" label=\"Show more\"" +wait "role=\"button\" label=\"Show less\"" 10000 +press "role=\"button\" label=\"Show less\"" +wait "role=\"button\" label=\"Show more\"" 10000 +close diff --git a/packages/app/e2e/mobile/appearance-keyboard/android.mjs b/packages/app/e2e/mobile/appearance-keyboard/android.mjs new file mode 100644 index 0000000000..1320a94d6b --- /dev/null +++ b/packages/app/e2e/mobile/appearance-keyboard/android.mjs @@ -0,0 +1,145 @@ +// Run against an Android dev client serving this checkout, with a real software keyboard. +// node packages/app/e2e/mobile/appearance-keyboard/android.mjs [app-id] +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv; +assert( + session && metroUrl && serverId, + "Pass the agent-device session, Metro URL and connected host ID", +); +const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android"); +mkdirSync(artifacts, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", ["--session", session, ...args], { + encoding: "utf8", + timeout: 60_000, + }); +} + +function evaluate(expression) { + return ad("cdp", "runtime", "eval", "--expr", expression, "--json"); +} + +// Read the mounted app's public React Native APIs through Metro. Accessibility +// snapshots with Gboard open can block while waiting for an idle hierarchy. +const reactNative = `Array.from(__r.getModules().values()).find( + m => m.verboseName?.endsWith("/react-native/index.js") +).publicModule.exports`; + +function measureFocusedInput() { + evaluate(`(() => { + const rn = ${reactNative}; + globalThis.__appearanceKeyboardMeasurement = null; + rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => { + globalThis.__appearanceKeyboardMeasurement = JSON.stringify({ + x, y, width, height, + statusBarHeight: rn.StatusBar.currentHeight, + scale: rn.PixelRatio.get(), + keyboard: rn.Keyboard.metrics(), + }); + }); + })()`); + const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement")); + return value ? JSON.parse(value) : null; +} + +async function waitForFocusedInput() { + let measurement; + let previous; + let stable = 0; + const deadline = Date.now() + 30_000; + do { + measurement = measureFocusedInput(); + const current = measurement?.keyboard + ? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",") + : null; + stable = measurement?.keyboard && current === previous ? stable + 1 : 0; + previous = current; + if (stable >= 3) break; + await delay(150); + } while (Date.now() < deadline); + + assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`); + return measurement; +} + +ad("open", appId, "--platform", "android", "--no-test-ime"); +const targets = ad("cdp", "target", "list", "--url", metroUrl) + .split("\n") + .filter((line) => line.includes(appId)); +assert.equal(targets.length, 1, "Connect only the test app to this Metro server"); +ad("cdp", "target", "select", targets[0].split(/\s+/)[0]); + +try { + evaluate(`(${reactNative}).Keyboard.dismiss()`); + ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime"); + ad("wait", "text", "Appearance", "5000"); + ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime"); + ad("wait", "text", "Code size", "5000"); + const snapshot = JSON.parse(ad("snapshot", "-i", "--json")); + const numericFields = snapshot.data.nodes.filter( + (node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""), + ); + assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling"); + ad("screenshot", resolve(artifacts, "before-keyboard.png")); + // Appearance orders Interface size, Content size, then Code size. + ad("press", `@${numericFields[2].ref}`); + + const measurement = await waitForFocusedInput(); + // Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates. + const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight; + const keyboardTop = measurement.keyboard.screenY; + console.log(JSON.stringify({ inputBottom, keyboardTop, measurement })); + ad("screenshot", resolve(artifacts, "keyboard.png")); + assert( + inputBottom <= keyboardTop + 1 / measurement.scale, + "Code size must be fully visible above the number keyboard (within one physical pixel)", + ); + console.log("PASS: focusing Code size reveals the whole field above the keyboard"); + + evaluate(`(${reactNative}).Keyboard.dismiss()`); + ad( + "open", + appId, + `paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`, + "--platform", + "android", + "--no-test-ime", + ); + ad("wait", "text", "Plugin source", "5000"); + const plugins = JSON.parse(ad("snapshot", "-i", "--json")); + const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText"); + assert(source, "The connected host must support installing plugins from source"); + ad("press", `@${source.ref}`); + ad("type", "qa-source"); + const pluginMeasurement = await waitForFocusedInput(); + // A FormTextInput draws its border and padding on the native input's parent. + // Measure that visible frame, rather than accepting only the text as visible. + const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes; + const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused); + assert(focused, "Plugin source must remain focused after typing"); + assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field"); + const frame = hierarchy.find((node) => node.index === focused.parentIndex); + assert(frame, "The source input's visible frame must be present in the native hierarchy"); + assert( + frame.rect.height > focused.rect.height, + "Measure the surrounding frame, including padding", + ); + const frameBottom = frame.rect.y + frame.rect.height; + const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale; + console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect })); + ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png")); + assert( + frameBottom <= pluginKeyboardTop + 1, + "Plugin source frame must be fully visible above the keyboard (within one physical pixel)", + ); + console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard"); +} finally { + evaluate(`(${reactNative}).Keyboard.dismiss()`); + evaluate("delete globalThis.__appearanceKeyboardMeasurement"); +} diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index 49c907dba9..809483ceff 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four' LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')" BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')" AGENT_TITLE="Keyboard dismiss QA $(date +%s)" +# `overlays` runs only each host's autocomplete, model selector, attachment menu, and +# forge picker checks; `all` also runs the chat baseline and the full host scenario. +SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}" +if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then + echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2 + exit 1 +fi if [[ -z "${SERVER_ID}" ]]; then if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then @@ -173,8 +180,10 @@ sleep 3 # Create the fixture while the client is connected so its directory replica observes the insert. ad wait 'id="message-input-root"' 90000 +# The compact header names the workspace, not the agent, so the chat waits below find +# the fixture by its prompt bubble. run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \ - npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \ + npm run --silent cli -- run "${AGENT_TITLE}" \ --background \ --title "${AGENT_TITLE}" \ --provider mock \ @@ -206,238 +215,241 @@ clear_input ad keyboard dismiss || true ad wait 'label="Message, @files, /commands"' 10000 -snapshot_json "${ARTIFACTS_DIR}/baseline.json" -capture_screen "${ARTIFACTS_DIR}/baseline.png" -read -r input_x input_y baseline_input_height < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands" -) -read -r model_x model_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector" -) -read -r attach_x attach_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button" -) -read -r _ header_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger" -) +# Chat-only invariants, then the long-draft checks on New workspace. +run_chat_baseline() { + snapshot_json "${ARTIFACTS_DIR}/baseline.json" + capture_screen "${ARTIFACTS_DIR}/baseline.png" + read -r input_x input_y baseline_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands" + ) + read -r model_x model_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector" + ) + read -r attach_x attach_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button" + ) + read -r _ header_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger" + ) -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" -clear_focused_input_with_adb -keyboard_shift="$(read_keyboard_shift)" -adb shell input keyevent 76 -sleep 1 -capture_screen "${ARTIFACTS_DIR}/command-popover.png" -ad wait 'id="composer-autocomplete-popover"' 10000 -ad wait 'text="/exit"' 10000 -model_open_y="$((model_y - keyboard_shift))" -adb shell input tap "${model_x}" "${model_open_y}" -sleep 1 -capture_screen "${ARTIFACTS_DIR}/model-selector-open.png" -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="compact-provider-list"' 10000 -ad press 'label="Close"' --settle -adb shell input tap "${input_x}" "${input_y}" -clear_focused_input_with_adb -ad wait 'editable=true' 10000 + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" + clear_focused_input_with_adb + keyboard_shift="$(read_keyboard_shift)" + adb shell input keyevent 76 + sleep 1 + capture_screen "${ARTIFACTS_DIR}/command-popover.png" + ad wait 'id="composer-autocomplete-popover"' 10000 + ad wait 'text="/exit"' 10000 + model_open_y="$((model_y - keyboard_shift))" + adb shell input tap "${model_x}" "${model_open_y}" + sleep 1 + capture_screen "${ARTIFACTS_DIR}/model-selector-open.png" + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="compact-provider-list"' 10000 + ad press 'label="Close"' --settle + adb shell input tap "${input_x}" "${input_y}" + clear_focused_input_with_adb + ad wait 'editable=true' 10000 -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" -clear_focused_input_with_adb -adb shell input keyevent 76 -sleep 1 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="composer-autocomplete-popover"' 10000 -ad wait 'text="/exit"' 10000 -snapshot_json "${ARTIFACTS_DIR}/command-popover.json" -node "${ASSERT}" above-y \ - "${ARTIFACTS_DIR}/command-popover.json" \ - "/exit, Archive the current agent" \ - "$((input_y - baseline_input_height / 2))" -adb shell input keyevent 67 -ad wait 'label="Message, @files, /commands"' 10000 + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" + clear_focused_input_with_adb + adb shell input keyevent 76 + sleep 1 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="composer-autocomplete-popover"' 10000 + ad wait 'text="/exit"' 10000 + snapshot_json "${ARTIFACTS_DIR}/command-popover.json" + node "${ASSERT}" above-y \ + "${ARTIFACTS_DIR}/command-popover.json" \ + "/exit, Archive the current agent" \ + "$((input_y - baseline_input_height / 2))" + adb shell input keyevent 67 + ad wait 'label="Message, @files, /commands"' 10000 -ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/multiline-first.json" -ad fill 'editable=true focused=true' "${MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/multiline-second.json" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/multiline-first.json" \ - "${ARTIFACTS_DIR}/multiline-second.json" + ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/multiline-first.json" + ad fill 'editable=true focused=true' "${MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/multiline-second.json" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/multiline-first.json" \ + "${ARTIFACTS_DIR}/multiline-second.json" -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -capture_screen "${ARTIFACTS_DIR}/keyboard-open.png" + keyboard_shift="$(read_keyboard_shift)" + capture_screen "${ARTIFACTS_DIR}/keyboard-open.png" -node "${ASSERT}" same-header \ - "${ARTIFACTS_DIR}/baseline.png" \ - "${ARTIFACTS_DIR}/keyboard-open.png" \ - "$((header_y + 48))" + node "${ASSERT}" same-header \ + "${ARTIFACTS_DIR}/baseline.png" \ + "${ARTIFACTS_DIR}/keyboard-open.png" \ + "$((header_y + 48))" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle -open_gboard "${input_x}" "${input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png" -node "${ASSERT}" xml-above-y \ - "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ - "Add attachment" \ - "$(read_ime_top)" -display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" -node "${ASSERT}" xml-composer-contained \ - "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ - "$(read_ime_top)" \ - "${display_density}" -# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to -# its baseline height. Fabric only re-measures the Android input when its text prop changes, -# so the editing input renders again after every edit -# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the -# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the -# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from -# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key -# row on the paseo-api35 layout. -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle -open_gboard "${input_x}" "${input_y}" -adb shell input keyevent BACK -wait_for_ime false -open_gboard "${input_x}" "${input_y}" -adb shell input keycombination 113 122 -adb shell input keyevent 20 20 20 20 20 20 20 20 -screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)" -backspace_x="$((screen_width - 85))" -backspace_y="$(($(read_ime_top) + 507))" -adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000 -adb shell input keycombination 113 123 -adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'label="Message, @files, /commands"' 10000 -snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/baseline.json" \ - "${ARTIFACTS_DIR}/after-hold-delete.json" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle + open_gboard "${input_x}" "${input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png" + node "${ASSERT}" xml-above-y \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "Add attachment" \ + "$(read_ime_top)" + display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" + node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" + # Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to + # its baseline height. Fabric only re-measures the Android input when its text prop changes, + # so the editing input renders again after every edit + # (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the + # report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the + # draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from + # the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key + # row on the paseo-api35 layout. + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle + open_gboard "${input_x}" "${input_y}" + adb shell input keyevent BACK + wait_for_ime false + open_gboard "${input_x}" "${input_y}" + adb shell input keycombination 113 122 + adb shell input keyevent 20 20 20 20 20 20 20 20 + screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)" + backspace_x="$((screen_width - 85))" + backspace_y="$(($(read_ime_top) + 507))" + adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000 + adb shell input keycombination 113 123 + adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'label="Message, @files, /commands"' 10000 + snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/baseline.json" \ + "${ARTIFACTS_DIR}/after-hold-delete.json" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${MESSAGE}" --settle -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${MESSAGE}" --settle + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" -read -r changes_x changes_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab" -) -adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))" -ad wait 'id="changes-header"' 10000 -capture_screen "${ARTIFACTS_DIR}/changes-control-open.png" -ad press 'label="Close Explorer sidebar"' --settle -ad wait "text=\"${AGENT_TITLE}\"" 10000 -ad wait 'editable=true' 10000 + read -r changes_x changes_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab" + ) + adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))" + ad wait 'id="changes-header"' 10000 + capture_screen "${ARTIFACTS_DIR}/changes-control-open.png" + ad press 'label="Close Explorer sidebar"' --settle + ad wait "text=\"${AGENT_TITLE}\"" 10000 + ad wait 'editable=true' 10000 -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))" -sleep 1 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="message-input-attachment-menu-content"' 10000 -ad press 540 600 --settle + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" + adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))" + sleep 1 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="message-input-attachment-menu-content"' 10000 + ad press 540 600 --settle -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -adb shell input tap 983 "$((attach_y - keyboard_shift))" -wait_for_ime false -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait text "keyboard invariant line one" 10000 -snapshot_json "${ARTIFACTS_DIR}/after-submit.json" -node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/baseline.json" \ - "${ARTIFACTS_DIR}/after-submit.json" + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" + adb shell input tap 983 "$((attach_y - keyboard_shift))" + wait_for_ime false + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait text "keyboard invariant line one" 10000 + snapshot_json "${ARTIFACTS_DIR}/after-submit.json" + node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/baseline.json" \ + "${ARTIFACTS_DIR}/after-submit.json" -open_gboard "${input_x}" "${input_y}" -adb shell input keyevent BACK -wait_for_ime false -sleep 0.5 -capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png" + open_gboard "${input_x}" "${input_y}" + adb shell input keyevent BACK + wait_for_ime false + sleep 0.5 + capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png" -open_gboard "${input_x}" "${input_y}" -node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" & -stall_pid="$!" -sleep 0.25 -adb shell input keyevent BACK -wait_for_ime false -sleep 0.5 -capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png" -wait "${stall_pid}" -capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png" -node "${ASSERT}" same-region \ - "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ - "${ARTIFACTS_DIR}/hidden-during-js-stall.png" \ - 1400 \ - 430 -node "${ASSERT}" same-region \ - "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ - "${ARTIFACTS_DIR}/hidden-after-js-stall.png" \ - 1400 \ - 430 + open_gboard "${input_x}" "${input_y}" + node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" & + stall_pid="$!" + sleep 0.25 + adb shell input keyevent BACK + wait_for_ime false + sleep 0.5 + capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png" + wait "${stall_pid}" + capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png" + node "${ASSERT}" same-region \ + "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ + "${ARTIFACTS_DIR}/hidden-during-js-stall.png" \ + 1400 \ + 430 + node "${ASSERT}" same-region \ + "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ + "${ARTIFACTS_DIR}/hidden-after-js-stall.png" \ + 1400 \ + 430 -ad press 'id="menu-button"' --settle -ad press 'id="sidebar-global-new-workspace"' --settle -ad wait 'id="workspace-create-submit"' 10000 -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "x" --settle -snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json" -read -r new_input_x new_input_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable" -) -open_gboard "${new_input_x}" "${new_input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" -# Typing more lines moves the setup fields up with the composer instead of -# hiding their bottom rows behind it. -adb shell input keycombination 113 123 -adb shell input keyevent 66 66 66 -adb shell input text grown -sleep 1 -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png" -node "${ASSERT}" xml-fields-follow-growth \ - "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \ - "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" + ad press 'id="menu-button"' --settle + ad press 'id="sidebar-global-new-workspace"' --settle + ad wait 'id="workspace-create-submit"' 10000 + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "x" --settle + snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json" + read -r new_input_x new_input_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable" + ) + open_gboard "${new_input_x}" "${new_input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" + # Typing more lines moves the setup fields up with the composer instead of + # hiding their bottom rows behind it. + adb shell input keycombination 113 123 + adb shell input keyevent 66 66 66 + adb shell input text grown + sleep 1 + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png" + node "${ASSERT}" xml-fields-follow-growth \ + "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \ + "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${LONG_MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" -read -r new_input_x new_input_y new_input_height < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" -) -# The editor shrinks when the keyboard opens. Its bottom stays anchored, while -# its old center can move into the setup fields after the height constraint. -new_input_y="$((new_input_y + new_input_height / 2 - 8))" -open_gboard "${new_input_x}" "${new_input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" -node "${ASSERT}" xml-composer-contained \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ - "$(read_ime_top)" \ - "${display_density}" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${LONG_MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" + read -r new_input_x new_input_y new_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" + ) + # The editor shrinks when the keyboard opens. Its bottom stays anchored, while + # its old center can move into the setup fields after the height constraint. + new_input_y="$((new_input_y + new_input_height / 2 - 8))" + open_gboard "${new_input_x}" "${new_input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" + node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" -# Dismissing the keyboard moves the composer down at its editing height and -# brings the setup fields back fully visible above it. -adb shell input keyevent BACK -wait_for_ime false -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" -node "${ASSERT}" xml-fields-above-composer \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + # Dismissing the keyboard moves the composer down at its editing height and + # brings the setup fields back fully visible above it. + adb shell input keyevent BACK + wait_for_ime false + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + node "${ASSERT}" xml-fields-above-composer \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" +} # All hosts run the same scenario; only navigation differs. # For the shared scenario, tap the editor's blank right edge once. Tapping @@ -470,20 +482,80 @@ press_composer_control() { adb shell input tap "${x}" "${y}" } +# Opens suggestions with the keyboard closed, before it opens, or after it settled; +# checks their bounds and taps the best match where it is drawn. Accessibility keeps +# reporting rows behind the keyboard, so existence is not evidence. It also drops the +# composer while suggestions are open, so the composer bounds come from a reference +# snapshot with an empty input in the keyboard state the suggestions are checked in. +check_autocomplete() { + local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6" + local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}" + if [[ "${order}" == keyboard-closed ]]; then + prepare_composer closed + snapshot_json "${prefix}-reference.json" + adb shell input text "${query}" + else + prepare_composer open + # The composer follows the keyboard animation, which ends after the IME frame settles. + sleep 1 + snapshot_json "${prefix}-reference.json" + if [[ "${order}" == before-keyboard ]]; then + adb shell ime set "${HELPER_IME}" >/dev/null + wait_for_ime false + adb shell input text "${query}" + open_editor_keyboard "${overlay_x}" "${overlay_y}" + else + adb shell input text "${query}" + fi + fi + ad wait 'id="composer-autocomplete-popover"' 10000 + # The popover remeasures once keyboard motion settles. + sleep 1 + capture_screen "${prefix}.png" + snapshot_json "${prefix}.json" + local ime_top="" + if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi + local best_x best_y + read -r best_x best_y < <( + node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}" + ) + adb shell input tap "${best_x}" "${best_y}" + sleep 1 + snapshot_json "${prefix}-selected.json" + node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}" + clear_focused_input_with_adb + echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted" +} + +# Drafts list commands for their selected provider, and a fresh install selects none. +select_claude_model() { + local x y h + press_composer_control combined-model-selector + ad wait 'id="agent-controls-model"' 10000 + ad press 'id="agent-controls-model"' --settle + ad press 'id="model-provider-claude"' --settle + snapshot_json "${ARTIFACTS_DIR}/model-list.json" + read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*') + adb shell input tap "${x}" "${y}" + ad wait 'id="agent-controls-thinking"' 10000 + ad press 'label="Close"' --settle +} + run_host_overlays() { local host="$1" + if [[ "${host}" != chat ]]; then + prepare_composer closed + select_claude_model + fi + # Chat lists the mock provider's command; drafts list the release skills in + # this repository's .claude/skills for the selected Claude model. + local command_query=/release command_match=/release-beta command_inserted="/release-beta " + if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi + for order in keyboard-closed before-keyboard after-keyboard; do + check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}" + check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"' + done for mode in closed open; do - for query in / @README; do - prepare_composer closed - adb shell input text "${query}" - if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi - ad wait 'id="composer-autocomplete-popover"' 10000 - local kind=commands - if [[ "${query}" == @* ]]; then kind=files; fi - capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png" - clear_focused_input_with_adb - echo "PASS host=${host}: ${kind} popover keyboard=${mode}" - done prepare_composer "${mode}" press_composer_control combined-model-selector ad wait 'id="compact-provider-list"' 10000 @@ -656,19 +728,29 @@ run_host_scenario() { run_host_scroll "${host}" } +run_host() { + if [[ "${SCENARIO}" == overlays ]]; then + run_host_overlays "$1" + else + run_host_scenario "$1" + fi +} + +if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null ad wait "text=\"${AGENT_TITLE}\"" 10000 -run_host_scenario chat +run_host chat ad keyboard dismiss || true ad press 'id="workspace-tab-switcher-trigger"' --settle ad wait 'id="workspace-tab-switcher-new-agent"' 10000 ad press 'id="workspace-tab-switcher-new-agent"' --settle -ad wait 'text="New Agent"' 10000 -run_host_scenario workspace-draft +# Only a draft offers to import a session. +ad wait 'id="composer-import-agent-pill"' 10000 +run_host workspace-draft ad keyboard dismiss || true ad press 'id="menu-button"' --settle ad press 'id="sidebar-global-new-workspace"' --settle ad wait 'id="workspace-create-submit"' 10000 -run_host_scenario new-workspace +run_host new-workspace echo "Composer keyboard invariants passed" diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs index 6bd13a5da5..637a162682 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import process from "node:process"; import sharp from "sharp"; +import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs"; const [, , command, ...args] = process.argv; @@ -115,11 +116,14 @@ if (command === "stream-moved") { `Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`, ); } else if (command === "rect") { + // A trailing `*` matches the first identifier with that prefix. const [snapshotPath, identifier, edge] = args; const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null; const node = snapshot.data.nodes.find( (candidate) => candidate.identifier === identifier || + (prefix !== null && candidate.identifier?.startsWith(prefix)) || candidate.label === identifier || (identifier === "editable" && candidate.type.endsWith("EditText")), ); @@ -232,6 +236,27 @@ if (command === "stream-moved") { if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) { throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`); } +} else if (command === "autocomplete-best") { + // Prints the center of the expected best match after checking its placement + // against the composer in the reference snapshot. + const [snapshotPath, referencePath, expected, imeTopArgument] = args; + const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const reference = JSON.parse(await fs.readFile(referencePath, "utf8")); + const best = resolveAutocompletePlacement(snapshot, { + expected, + keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined, + reference, + }); + process.stdout.write(`${best.x} ${best.y}\n`); +} else if (command === "composer-text") { + // Accessibility drops a trailing space, so compare without it. + const [snapshotPath, expected] = args; + const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText")); + const text = input?.value ?? input?.label ?? ""; + if (text.trimEnd() !== expected.trimEnd()) { + throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`); + } } else if (command === "same-region") { const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args; const crop = { diff --git a/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs new file mode 100644 index 0000000000..a8450838eb --- /dev/null +++ b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs @@ -0,0 +1,101 @@ +// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device +// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that +// sit behind the keyboard, so existence is not evidence: compare their screen bounds. + +const bottomOf = (rect) => rect.y + rect.height; + +function findById(nodes, identifier) { + const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect); + if (!node) throw new Error(`Missing node: ${identifier}`); + return node; +} + +function contains(outer, inner) { + return ( + inner.x >= outer.x - 1 && + inner.y >= outer.y - 1 && + inner.x + inner.width <= outer.x + outer.width + 1 && + bottomOf(inner) <= bottomOf(outer) + 1 + ); +} + +/** + * The best match is the list row nearest the composer. Check that the popover sits + * between the app's header controls and the composer, that the keyboard (when shown) + * is below both, and that the row nearest the composer is fully drawn and is + * `expected`. A clipped best match fails: the nearest whole row is then another one. + * Return where to tap it. Android leaves the composer out of the accessibility tree + * while suggestions are open, so its callers read the composer from `reference`: a + * snapshot in the same keyboard state with an empty input. + */ +export function resolveAutocompletePlacement( + snapshot, + { expected, keyboardTop, reference, anchorGap }, +) { + if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot"); + const nodes = snapshot.data.nodes; + const popover = findById(nodes, "composer-autocomplete-popover").rect; + const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect; + // The popover may cover the compact header, never the status bar above it. + const minimumTop = + nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0; + // iOS reports the keyboard in the app's tree; Android callers pass the IME frame. + const keyboardY = + keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y; + const popoverBottom = bottomOf(popover); + const gap = composer.y - popoverBottom; + if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) { + throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`); + } + if (popoverBottom > composer.y + 1) { + throw new Error( + `Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`, + ); + } + if (popover.y < minimumTop - 1) { + throw new Error( + `Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`, + ); + } + if (keyboardY !== undefined && popoverBottom > keyboardY + 1) { + throw new Error( + `Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`, + ); + } + // Rows are the innermost labelled boxes spanning the popover. The list scrolls to + // its end, so the lowest row is the best match nearest the composer. + const candidates = nodes.filter( + (node) => + node.rect && + node.label && + node.identifier !== "composer-autocomplete-popover" && + !/scroll bar/i.test(node.label) && + node.rect.height > 0 && + // The popover border insets rows by a few pixels; their labels are narrower. + node.rect.width >= popover.width * 0.97 && + contains(popover, node.rect), + ); + const area = (rect) => rect.width * rect.height; + const rows = candidates.filter( + (outer) => + !candidates.some( + (inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect), + ), + ); + if (rows.length === 0) throw new Error("Autocomplete has no visible rows"); + const best = rows.reduce((lowest, row) => + bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest, + ); + const label = best.label.split(", ")[0]; + if (label !== expected) { + throw new Error(`Best visible match is ${label}, expected ${expected}`); + } + return { + label, + x: Math.round(best.rect.x + best.rect.width / 2), + y: Math.round(best.rect.y + best.rect.height / 2), + popover, + composer, + keyboardTop: keyboardY ?? null, + }; +} diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs new file mode 100644 index 0000000000..513d68623e --- /dev/null +++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs @@ -0,0 +1,340 @@ +// Composer autocomplete placement on iOS with the software keyboard: chat, workspace +// draft tab, and New workspace. Run where agent-device reaches the simulator, with the +// development app connected to the fixture host. See docs/mobile-testing.md. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../.."); +const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback; +const serverId = env("SERVER_ID"); +const agentId = env("AGENT_ID"); +const workspaceId = env("WORKSPACE_ID"); +const scenario = env("SCENARIO", "all"); +if (!serverId || !workspaceId || (scenario === "all" && !agentId)) { + throw new Error( + "Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)", + ); +} +const draftId = env("DRAFT_ID", "composer-keyboard"); +const session = env("SESSION", "composer-keyboard-ios"); +const device = env("DEVICE", "iPhone 16 Pro"); +const udid = env("UDID"); +const deviceArgs = udid ? ["--udid", udid] : ["--device", device]; +const artifactsDir = env( + "ARTIFACTS_DIR", + path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"), +); +const grownPrefix = "grown line one\ngrown line two\ngrown line three\n"; +// Partial queries with known best matches. `inserted` is the composer text after +// tapping the best match. +const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' }; +// The release skills ship in this repository's .claude/skills. +const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " }; +// The chat fixture's mock provider lists one command. +const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " }; + +fs.mkdirSync(artifactsDir, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); +} + +function snapshot(name) { + const output = ad("snapshot", "--raw", "--json"); + fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output); + ad("screenshot", path.join(artifactsDir, `${name}.png`)); + return JSON.parse(output); +} + +const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect); +const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard"); +const composerText = (snap) => + snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? ""; + +async function waitFor(name, predicate, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const snap = snapshot(name); + if (predicate(snap)) return snap; + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`); + await sleep(500); + } +} + +// Keyboard motion has settled once two snapshots agree on the composer position. +async function settledPlacement(name, keyboard) { + let previousTop; + return waitFor(name, (snap) => { + const composer = nodeById(snap, "message-input-root"); + if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; + if (keyboardShown(snap) !== keyboard) return false; + const stable = composer.rect.y === previousTop; + previousTop = composer.rect.y; + return stable; + }); +} + +function assertPlacement(name, snap, expected) { + const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 }); + process.stdout.write( + `PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round( + placement.popover.y + placement.popover.height, + )} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`, + ); + return placement; +} + +// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a +// draft has no stream, so open and close the model sheet, which takes input focus. +async function dismissKeyboard(snap) { + const composer = nodeById(snap, "message-input-root").rect; + ad("press", "4", String(Math.round(composer.y - 8))); + await sleep(1_000); + if (!keyboardShown(snapshot("dismiss-keyboard"))) return; + const sheetOpen = (sheet) => + sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close"); + ad("press", 'id="combined-model-selector"'); + await waitFor("dismiss-keyboard-sheet", sheetOpen); + ad("press", 'label="Close"'); + // The input is not hittable until the sheet has gone. + await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done)); +} + +// Tap past the end of the (short) last line so the caret lands after the mention. +// The input's right padding holds the fullscreen toggle; stay clear of it. +function focusAtEnd(snap) { + const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect; + if (!input) throw new Error("Missing composer input"); + ad( + "press", + String(Math.round(input.x + input.width * 0.7)), + String(Math.round(input.y + input.height - 6)), + ); +} + +// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end. +async function clearInput(name) { + focusAtEnd(snapshot(`${name}-before-clear`)); + const keyboard = await waitFor(`${name}-clear`, keyboardShown); + const deleteKey = keyboard.data.nodes.find( + (node) => node.type === "Key" && node.label === "delete" && node.rect, + )?.rect; + if (!deleteKey) throw new Error("Missing keyboard delete key"); + const length = composerText(keyboard).length; + for (let remaining = length + 2; remaining > 0; remaining -= 100) { + ad( + "press", + String(Math.round(deleteKey.x + deleteKey.width / 2)), + String(Math.round(deleteKey.y + deleteKey.height / 2)), + "--count", + String(Math.min(remaining, 100)), + "--interval-ms", + "40", + ); + } +} + +// The accessibility value drops a trailing space. +const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd(); + +async function selectRow(name, row, inserted) { + ad("press", String(row.x), String(row.y)); + const selected = await waitFor(`${name}-selected`, (snap) => + sameText(composerText(snap), inserted), + ); + if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`); + process.stdout.write( + `PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`, + ); +} + +async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) { + const name = `${host}-${kind}`; + await clearInput(name); + await dismissKeyboard(snapshot(`${name}-empty`)); + // fill taps the input and types at once, so suggestions open while the keyboard opens. + ad("fill", "editable=true", text); + assertPlacement( + `${name} opened while keyboard opens`, + await settledPlacement(`${name}-opening`, true), + expected, + ); + await clearInput(`${name}-settled`); + await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === ""); + ad("type", text); + const opened = assertPlacement( + `${name} opened after keyboard settled`, + await settledPlacement(`${name}-open`, true), + expected, + ); + if (selectionKeyboard === "shown") { + await selectRow(name, opened, inserted); + return; + } + await dismissKeyboard(snapshot(`${name}-before-close`)); + const closed = assertPlacement( + `${name} keyboard closed`, + await settledPlacement(`${name}-closed`, false), + expected, + ); + if (selectionKeyboard === "hidden") { + await selectRow(name, closed, inserted); + return; + } + focusAtEnd(snapshot(`${name}-before-reopen`)); + const reopened = assertPlacement( + `${name} keyboard opened under open suggestions`, + await settledPlacement(`${name}-reopened`, true), + expected, + ); + await selectRow(name, reopened, inserted); +} + +// Drafts list commands for their selected provider, and a fresh install selects none. +async function selectClaudeModel(host) { + ad("press", 'id="combined-model-selector"'); + await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model"))); + ad("press", 'id="agent-controls-model"'); + await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude"))); + ad("press", 'id="model-provider-claude"'); + const models = await waitFor(`${host}-models`, (snap) => + snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect), + ); + const row = models.data.nodes.find( + (node) => node.identifier?.startsWith("model-row-claude-") && node.rect, + ).rect; + ad( + "press", + String(Math.round(row.x + row.width / 2)), + String(Math.round(row.y + row.height / 2)), + ); + await waitFor(`${host}-model-selected`, (snap) => + Boolean(nodeById(snap, "agent-controls-thinking")), + ); + ad("press", 'label="Close"'); + await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model")); +} + +async function runHost(host) { + if (host !== "chat") await selectClaudeModel(host); + await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase); + await runCase(host, "files", fileCase); + await runCase(host, "files-grown", { + text: `${grownPrefix}${fileCase.text}`, + expected: fileCase.expected, + inserted: `${grownPrefix}${fileCase.inserted}`, + }); + await clearInput(`${host}-done`); + await dismissKeyboard(snapshot(`${host}-done`)); +} + +// These cases use the selected draft provider's real skills. Run on a 402pt-wide +// iPhone for growth and a small phone (SE) for previews, with Codex selected. +async function runLiveGrowth() { + await clearInput("live-growth"); + ad("type", "Please do the following now /paseo"); + const before = await settledPlacement("live-growth-before", true); + assertPlacement("before growth", before, "/paseo"); + const inputHeight = (snap) => + snap.data.nodes.find((node) => node.type === "TextView").rect.height; + ad("type", "--", "-committee"); + const grown = await settledPlacement("live-growth-after", true); + if (inputHeight(grown) <= inputHeight(before)) + throw new Error("Draft did not wrap onto a new line"); + assertPlacement("live growth without reopening", grown, "/paseo-committee"); + const deleteKey = grown.data.nodes.find( + (node) => node.type === "Key" && node.label === "delete", + ).rect; + ad( + "press", + String(deleteKey.x + deleteKey.width / 2), + String(deleteKey.y + deleteKey.height / 2), + "--count", + "10", + "--interval-ms", + "40", + ); + const shrunk = await settledPlacement("live-growth-shrunk", true); + if (inputHeight(shrunk) !== inputHeight(before)) + throw new Error("Draft did not shrink back to one line"); + assertPlacement("live shrink without reopening", shrunk, "/paseo"); + ad("type", "--", "-committ"); + const partial = await settledPlacement("live-growth-partial", true); + if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) { + throw new Error("Could not prepare a partial command for selection"); + } + const row = assertPlacement("partial command after growth", partial, "/paseo-committee"); + ad("press", String(row.x), String(row.y)); + await waitFor("live-growth-selected", (snap) => + sameText(composerText(snap), "Please do the following now /paseo-committee "), + ); + await clearInput("live-growth-done"); +} + +async function runBoundedPreview() { + const prefix = + "Please do the following / testing inline selection.\n" + + "Line two with details.\nLine three with details.\nLine four with details.\n" + + "Line five with details.\nLine six with details.\nLine seven with details.\nPlease use "; + await runCase("preview", "long", { + text: `${prefix}/openai`, + expected: "/openai-docs", + inserted: `${prefix}/openai-docs `, + selectionKeyboard: "shown", + }); + await runCase("preview", "long-hidden", { + text: `${prefix}/openai`, + expected: "/openai-docs", + inserted: `${prefix}/openai-docs `, + selectionKeyboard: "hidden", + }); + await runCase("preview", "longest", { + text: `${prefix}/paseo-plug`, + expected: "/paseo-plugin", + inserted: `${prefix}/paseo-plugin `, + selectionKeyboard: "shown", + }); + await runCase("preview", "short", { + text: `${prefix}/com`, + expected: "/commit", + inserted: `${prefix}/commit `, + selectionKeyboard: "shown", + }); + await runCase("preview", "files", fileCase); +} + +if (scenario !== "all") { + ad( + "open", + `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`, + "--platform", + "ios", + ...deviceArgs, + ); + await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); + if (scenario === "growth") await runLiveGrowth(); + else if (scenario === "previews") await runBoundedPreview(); + else throw new Error(`Unknown scenario: ${scenario}`); + process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`); + process.exit(0); +} + +const hosts = [ + ["chat", `paseo://h/${serverId}/agent/${agentId}`], + ["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`], + ["new-workspace", `paseo://new?serverId=${serverId}`], +]; +for (const [host, url] of hosts) { + ad("open", url, "--platform", "ios", ...deviceArgs); + await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); + await runHost(host); +} +process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`); diff --git a/packages/app/e2e/mobile/composer-overlay/README.md b/packages/app/e2e/mobile/composer-overlay/README.md new file mode 100644 index 0000000000..d7a1ec4b62 --- /dev/null +++ b/packages/app/e2e/mobile/composer-overlay/README.md @@ -0,0 +1,47 @@ +# Late composer pill clearance + +This regression opens a completed conversation, relaunches the Android app, and +registers a composer pill five seconds after the client plugin loads. It checks +that the completed turn's Copy and Fork buttons remain fully above the pill. +The daemon, plugin API, native app, layout, and accessibility bounds are real; +no provider calls are needed during the test. + +Prerequisites: + +- Build the server and app dependencies with `npm run build:server` and + `npm run build --workspace=@getpaseo/expo-two-way-audio`. +- Run an isolated development daemon on a non-default port. +- Run this checkout's Metro server and open the connected Android dev client + with `agent-device`. The app must already know the isolated daemon. +- Open a completed conversation on a compact portrait Android emulator, at the + latest turn, with no other composer tracks visible. Keep the software keyboard + closed. Note the workspace and agent IDs from the daemon. + +Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the +repository root, substituting the session and ports: + +```sh +PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \ + node packages/app/e2e/mobile/composer-overlay/android.mjs \ + composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193 +``` + +The runner installs and removes its delayed plugin and leaves the conversation +intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory +contains the cold-reopen screenshot and raw accessibility snapshot. Without an +explicit directory, temporary evidence is removed with the fixture. + +To check an existing capture independently: + +```sh +node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \ + .dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill' +``` + +On the failing baseline, the Copy button extends below the pill's top edge. With +the correction, both actions are clear before the composer receives focus. + +Additional checks on the same conversation: opening the keyboard keeps the footer +clear; after scrolling up, a newly registered pill must not jump back to the latest +turn. Native iOS uses a different keyboard implementation and needs its own device +verification. diff --git a/packages/app/e2e/mobile/composer-overlay/android.mjs b/packages/app/e2e/mobile/composer-overlay/android.mjs new file mode 100644 index 0000000000..99f56df41e --- /dev/null +++ b/packages/app/e2e/mobile/composer-overlay/android.mjs @@ -0,0 +1,95 @@ +// Start on a completed conversation in the connected Android dev client. +// node android.mjs [app-id] [metro-port] +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocket } from "ws"; +import { DaemonClient } from "../../../../client/dist/daemon-client.js"; + +const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] = + process.argv; +assert( + session && daemonUrl && workspaceId && agentId, + "Pass a session, isolated daemon URL, workspace ID, and agent ID", +); +const url = new URL(daemonUrl); +assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon"); +assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon"); +const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-")); +const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence"); +await mkdir(artifacts, { recursive: true }); +const pluginId = "composer-overlay-mobile-repro"; +const pillLabel = "Overlay regression pill"; +const client = new DaemonClient({ + url: daemonUrl, + clientId: "composer-overlay-mobile-regression", + clientType: "cli", + appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version, + webSocketFactory: (address, options) => new WebSocket(address, options), +}); +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + timeout: 90_000, + }); +} +let installed = false; +let previousConfig; +try { + ad("wait", 'label="Copy turn"', "20000"); + await client.connect(); + previousConfig = (await client.getDaemonConfig()).config; + await client.patchDaemonConfig({ pluginsEnabled: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: pluginId, + requirements: { paseo: ">=0.11.4" }, + }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + `export default function contribute(client) { + let pill; + const timer = setTimeout(() => { + pill = client.addComposerPill({ + id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)}, + button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } }, + }); + }, 5000); + return () => { clearTimeout(timer); pill?.remove(); }; + }`, + ); + await client.installDirectoryPlugin(directory); + installed = true; + ad("wait", `label="${pillLabel}"`, "20000"); + // Match the report: reopen a completed conversation, then let the plugin register late. + ad( + "open", + appId, + "--platform", + "android", + "--metro-port", + metroPort, + "--no-test-ime", + "--relaunch", + ); + ad("wait", `label="${pillLabel}"`, "20000"); + const capture = path.join(artifacts, "cold-reopen.json"); + await writeFile(capture, ad("snapshot", "--raw", "--json")); + ad("screenshot", path.join(artifacts, "cold-reopen.png")); + execFileSync( + process.execPath, + [fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel], + { stdio: "inherit" }, + ); +} finally { + if (installed) await client.removePlugin(pluginId); + if (previousConfig) + await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled }); + await client.close(); + await rm(directory, { recursive: true, force: true }); +} diff --git a/packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs b/packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs new file mode 100644 index 0000000000..b091093254 --- /dev/null +++ b/packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs @@ -0,0 +1,21 @@ +// Assert geometry from a fresh agent-device snapshot --raw --json capture. +// node assert-footer-clear.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const [, , snapshotPath, pillLabel] = process.argv; +assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label"); +const { data } = JSON.parse(readFileSync(snapshotPath, "utf8")); +const buttons = data.nodes.filter((node) => node.type === "android.widget.Button"); +const pill = buttons.find((node) => node.label === pillLabel); +assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`); +for (const label of ["Copy turn", "Fork chat from here"]) { + const button = buttons.find((node) => node.label === label); + assert(button?.rect, `Completed-turn action '${label}' must be visible`); + const bottom = button.rect.y + button.rect.height; + assert( + bottom <= pill.rect.y, + `${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`, + ); +} +console.log("PASS: completed-turn actions remain fully above the composer pill"); diff --git a/packages/app/e2e/mobile/fold/android.mjs b/packages/app/e2e/mobile/fold/android.mjs new file mode 100644 index 0000000000..93f23dc111 --- /dev/null +++ b/packages/app/e2e/mobile/fold/android.mjs @@ -0,0 +1,104 @@ +// Run a release build with a connected workspace open and the keyboard hidden. +// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const serial = process.env.ANDROID_SERIAL; +assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator"); +const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug"; +const session = "android-fold"; +const artifacts = resolve(".dev/agent-device-artifacts/android-fold"); +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"), +}; +mkdirSync(artifacts, { recursive: true }); + +function run(binary, args) { + return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 }); +} +function adb(...args) { + return run("adb", ["-s", serial, ...args]); +} +function ad(...args) { + return run("agent-device", [...args, "--session", session]); +} +function pid() { + return adb("shell", "pidof", appId).trim(); +} +function open() { + ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime"); +} +function stateId(name) { + const states = adb("shell", "cmd", "device_state", "print-states"); + const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`)); + assert(match, `Emulator must expose the ${name} posture: ${states}`); + return match[1]; +} + +async function expectLayout(posture) { + const compact = posture === "CLOSED"; + const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll"; + const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger"; + const deadline = Date.now() + 15_000; + let nodes = []; + do { + let data; + try { + ({ data } = JSON.parse(ad("snapshot", "-i", "--json"))); + } catch (error) { + // During display handoff Android briefly has no capturable app window. + if (!error.stdout?.includes("insufficient foreground app content")) throw error; + await delay(200); + continue; + } + assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo"); + nodes = data.nodes; + if ( + nodes.some((node) => node.identifier === expectedId) && + !nodes.some((node) => node.identifier === unexpectedId) + ) { + writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2)); + console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`); + return; + } + await delay(200); + } while (Date.now() < deadline); + writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2)); + writeFileSync( + resolve(artifacts, "native-failure.txt"), + adb("shell", "dumpsys", "activity", "top"), + ); + assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`); +} + +const closed = stateId("CLOSED"); +const opened = stateId("OPENED"); +const initialState = adb("shell", "cmd", "device_state", "state").match( + /Committed state: DeviceState\{identifier=(\d+)/, +)?.[1]; +assert(initialState, "Could not read the initial fold state"); + +try { + adb("shell", "cmd", "device_state", "state", opened); + open(); + await expectLayout("OPENED"); + const initialPid = pid(); + for (const [posture, id] of [ + ["CLOSED", closed], + ["OPENED", opened], + ]) { + adb("shell", "input", "keyevent", "KEYCODE_HOME"); + adb("shell", "cmd", "device_state", "state", id); + // Resume immediately: waiting for configuration delivery hides the regression. + open(); + await expectLayout(posture); + assert.equal(pid(), initialPid, "Fold/resume must preserve the app process"); + } +} finally { + adb("shell", "cmd", "device_state", "state", initialState); + ad("close"); +} diff --git a/packages/app/e2e/mobile/markdown-formatting/README.md b/packages/app/e2e/mobile/markdown-formatting/README.md new file mode 100644 index 0000000000..28cdb87c6b --- /dev/null +++ b/packages/app/e2e/mobile/markdown-formatting/README.md @@ -0,0 +1,41 @@ +# Inline code formatting + +This fixture renders the production `MarkdownRenderer` with plain, bold, italic, +combined emphasis, strikethrough and linked code, plus bold code in headings, +lists and quotes. Toggle regular/compact text. Inline code uses the theme’s +code-to-body size ratio at every heading level; fenced code keeps the code-size +token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use +line metrics. + +Use a development client rebuilt from this checkout. Follow the temporary entry +point and Metro workflow in [status-ring](../status-ring/README.md), importing +`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`. +Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward. + +With this fixture open in an Android Agent Device session, run: + +```sh +node packages/app/e2e/mobile/markdown-formatting/android.mjs +``` + +The runner switches to paragraphs with long inline code at a fixed 300 dp width. +It compares the native Markdown host’s bottom with its last TextView’s bottom, +then repeats in compact mode and after restoring regular text. More than one +pixel of surplus fails. Keep the whole fixture visible: clipped accessibility +bounds cannot establish measurement parity. + +`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and +compares them with the shipped headers. This catches missing C++ fields in the +measurement path, which the Kotlin tests cannot exercise. + +The package patch contains `InlineCodeFormattingTest`, which exercises actual +Android spans in Robolectric native graphics mode, covering draw and measurement, +span order, code inside paragraph/heading sizes, bold/italic composition, and +links preserving code and emphasized prose fonts. +It runs in the existing `test:android` CI job. To run only this regression locally, +from `packages/app/android` after Expo prebuild: + +```sh +./gradlew :react-native-enriched-markdown:testDebugUnitTest \ + --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 +``` diff --git a/packages/app/e2e/mobile/markdown-formatting/android.mjs b/packages/app/e2e/mobile/markdown-formatting/android.mjs new file mode 100644 index 0000000000..c015edcb3a --- /dev/null +++ b/packages/app/e2e/mobile/markdown-formatting/android.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +const session = process.argv[2]; +assert(session, "Pass an Android agent-device session serving native.tsx"); +function ad(...args) { + return execFileSync("agent-device", ["--session", session, ...args], { + encoding: "utf8", + timeout: 60_000, + }); +} + +function assertMeasurementParity(mode) { + const snapshot = JSON.parse(ad("snapshot", "--raw", "--json")); + assert.equal(snapshot.success, true); + const nodes = snapshot.data.nodes; + let last = nodes.find((node) => node.label?.endsWith("End of measurement fixture.")); + assert(last?.rect, "The last paragraph must be visible, including its bottom"); + // EnrichedMarkdownText exposes paragraph accessibility nodes inside its TextView; + // EnrichedMarkdown exposes the TextViews themselves. Measure the native child. + let host = nodes.find((node) => node.index === last.parentIndex); + if (host?.type === "android.widget.TextView") { + last = host; + host = nodes.find((node) => node.index === last.parentIndex); + } + assert.equal(host?.type, "android.widget.FrameLayout"); + const viewport = nodes.find((node) => node.type === "android.widget.ScrollView"); + const footer = nodes.find((node) => node.label === "End of Markdown host"); + assert(viewport?.rect && footer?.rect, "The fixture and footer must be visible"); + assert( + host.rect.y >= viewport.rect.y && + footer.rect.y + footer.rect.height <= viewport.rect.y + viewport.rect.height, + "The whole fixture must fit in the viewport; clipped bounds cannot prove parity", + ); + const hostBottom = host.rect.y + host.rect.height; + const childBottom = last.rect.y + last.rect.height; + console.log( + JSON.stringify({ + mode, + host: host.rect, + lastChild: last.rect, + surplus: hostBottom - childBottom, + }), + ); + assert( + Math.abs(hostBottom - childBottom) <= 1, + `Markdown reserves blank space in ${mode}: host bottom ${hostBottom}, last child bottom ${childBottom}`, + ); +} + +ad("press", 'label="Show wrapping fixture"'); +ad("wait", "text", "End of Markdown host", "10000"); +assertMeasurementParity("regular"); +ad("press", 'label="Use compact text"'); +ad("wait", "text", "Use regular text", "10000"); +assertMeasurementParity("compact"); +ad("press", 'label="Use regular text"'); +ad("wait", "text", "Use compact text", "10000"); +assertMeasurementParity("regular restored"); +console.log("PASS Android Markdown host measurement matches its last child"); diff --git a/packages/app/e2e/mobile/markdown-formatting/native.tsx b/packages/app/e2e/mobile/markdown-formatting/native.tsx new file mode 100644 index 0000000000..e28f8a9900 --- /dev/null +++ b/packages/app/e2e/mobile/markdown-formatting/native.tsx @@ -0,0 +1,68 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { Button, ScrollView, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; + +const styles = StyleSheet.create((theme) => ({ + root: { flex: 1 }, + scroll: { backgroundColor: theme.colors.surface0 }, + content: { padding: 32, paddingTop: 60 }, + title: { color: theme.colors.foreground }, + wrapping: { width: 300 }, +})); + +const queryClient = new QueryClient(); + +const source = [ + "Plain: `MMMMiiii`", + "Bold: **`MMMMiiii`**", + "Italic: *`MMMMiiii`*", + "Both: ***`MMMMiiii`***", + "Strike: ~~`MMMMiiii`~~", + "Link: [`MMMMiiii`](https://example.com)", + "# Heading **`MMMMiiii`**", + "## Heading *`MMMMiiii`*", + "### Heading [`MMMMiiii`](https://example.com)", + "#### Heading ~~`MMMMiiii`~~", + "##### Heading ***`MMMMiiii`***", + "###### Heading `MMMMiiii`", + "- List **`MMMMiiii`**", + "> Quote **`MMMMiiii`**", + "Normal **bold** and *italic* prose.", +].join("\n\n"); + +const wrappingSource = [ + "Measurement parity. Take a repo with a gitignored `.env` in the main checkout and a `.worktreeinclude` containing `.env`.", + "Every creation path goes through `createPaseoWorktreeWorkflow` in `worktree-session.ts:341`, before `createAgent` in `creation/index.ts:160-174` starts.", + "The built-in plugin lives in `plugins/worktree-include/index.server.ts` and copies `paseo.json` beside `.worktreeinclude`. End of measurement fixture.", +].join("\n\n"); + +export default function MarkdownFormattingRegression() { + const [wrapping, setWrapping] = useState(false); + const toggleWrapping = useCallback(() => setWrapping((value) => !value), []); + const [compact, setCompact] = useState(false); + const toggleCompact = useCallback(() => setCompact((value) => !value), []); + return ( + + + + Inline code formatting regression + ", + ); + expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([ + "needle", + "needle", + ]); + expect(findRenderedMatches(row, "Promptfind")).toEqual([]); + expect(findRenderedMatches(row, "find this needle")).toHaveLength(1); + expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2); +}); + +it("leaves rows without the plugin marker limited to marked message text", () => { + const row = document.createElement("div"); + row.innerHTML = "
needle
"; + document.body.append(row); + expect(findRenderedMatches(row, "needle")).toEqual([]); +}); + +it("counts only drawn text in a plugin row, including an opened popover", () => { + const row = pluginRow( + '
visible needle
display needle
visibility needletransparent needle
popover needle
', + ); + expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]); + row.querySelector("[popover]")!.showPopover(); + expect(findRenderedMatches(row, "needle")).toHaveLength(2); +}); + +it("keeps text around nested plugin blocks in separate occurrences", () => { + const row = pluginRow("
alpha
details
beta
gamma
delta
"); + expect(findRenderedMatches(row, "alpha beta")).toEqual([]); + expect(findRenderedMatches(row, "gamma delta")).toEqual([]); + expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]); + expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]); +}); diff --git a/packages/app/src/agent-stream/chat-find/ranges.web.ts b/packages/app/src/agent-stream/chat-find/ranges.web.ts index ea2e901c2d..13da1034a2 100644 --- a/packages/app/src/agent-stream/chat-find/ranges.web.ts +++ b/packages/app/src/agent-stream/chat-find/ranges.web.ts @@ -21,28 +21,55 @@ export function findMessageMatches( return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query)); } +function renderedTextGroups(row: HTMLElement): Text[][] { + const groups: Text[][] = []; + // A plugin renderer owns the DOM of the message it replaced and sets no markers, so the + // whole row is searched and each div (a React Native View or Text) bounds a match. + const pluginRow = row.dataset.pluginRow === "true"; + const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS; + const contents = pluginRow + ? [row] + : row.querySelectorAll('[data-message-text="true"]'); + for (const content of contents) { + let group: { block: Element; texts: Text[] } | undefined; + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node instanceof Element) { + // Even an empty nested block separates the text before and after it. + if (node.matches(blocks)) group = undefined; + continue; + } + if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue; + if ( + pluginRow && + !node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true }) + ) + continue; + const block = node.parentElement?.closest(blocks) ?? content; + if (group?.block !== block) { + group = { block, texts: [] }; + groups.push(group.texts); + } + group.texts.push(node); + } + } + return groups; +} + /** Local offsets belong to the DOM that supplied the text, never to a host parser. */ export function findRenderedMatches(row: HTMLElement, query: string): Range[] { if (!query.trim()) return []; - const groups = new Map(); - for (const content of row.querySelectorAll('[data-message-text="true"]')) { - const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); - let node: Node | null; - while ((node = walker.nextNode())) { - if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue; - const block = node.parentElement?.closest(BLOCKS) ?? content; - const texts = groups.get(block) ?? []; - texts.push(node); - groups.set(block, texts); - } - } const pattern = query .trim() .split(/\s+/) .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join("\\s+"); const ranges: Range[] = []; - for (const texts of groups.values()) { + for (const texts of renderedTextGroups(row)) { const points = texts.map((node) => ({ node, start: 0, end: 0 })); let text = ""; for (const point of points) { diff --git a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts index a7cf29994c..1b4b8d18e8 100644 --- a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts +++ b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts @@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) { const handle: StreamViewportHandle = { scrollToBottom() {}, prepareForViewportChange() {}, + holdReadingPosition() {}, scrollToMessage(_messageId, occurrence) { if (occurrence) capture.occurrence = occurrence; }, diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index bd5a73ce94..890f6eaae7 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -265,6 +265,7 @@ describe("useChatOutline", () => { const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), + holdReadingPosition: vi.fn(), scrollToMessage, }; const viewportRef = { current: viewport }; @@ -316,6 +317,7 @@ describe("useChatOutline", () => { current: { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), + holdReadingPosition: vi.fn(), scrollToMessage, }, }; @@ -371,6 +373,7 @@ describe("useChatOutline", () => { const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), + holdReadingPosition: vi.fn(), scrollToMessage, }; const livePrompt = { diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index 5fa3535bce..341ce75583 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) isTurnActive: input.isTurnActive, history: strategy.orderTail([userMessage("user"), assistant]), liveHead: [], - timingByAssistantId: timingFor(assistant.id), + timingByItemId: timingFor(assistant.id), }), }; } diff --git a/packages/app/src/agent-stream/history-window.ts b/packages/app/src/agent-stream/history-window.ts index 347f780023..43cca2bbbf 100644 --- a/packages/app/src/agent-stream/history-window.ts +++ b/packages/app/src/agent-stream/history-window.ts @@ -1,4 +1,5 @@ import type { StreamItem } from "@/types/stream"; +import { continuesTurn } from "./turn-membership"; export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20; @@ -32,7 +33,7 @@ export function findMountedWindowStart(input: { } let startIndex = Math.max(items.length - minMountedCount, 0); - while (startIndex > 0 && items[startIndex]?.kind !== "user_message") { + while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) { startIndex -= 1; } return startIndex; diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index f10fde2cb1..a86817b6a7 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -104,7 +104,7 @@ function layoutFor(input: { strategy, streamHead: input.head ?? [], }), - timingByAssistantId: timingFor(...(input.timingIds ?? [])), + timingByItemId: timingFor(...(input.timingIds ?? [])), }); } @@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { - it("places one response footer after an adjacent tagged tool-only turn", () => { + it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => { const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const nextTool = toolCall("next-tool", 2, "turn-2"); const layout = layoutFor({ @@ -157,8 +157,8 @@ describe("layoutStream", () => { timingIds: [priorAssistant.id], }); - expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id); - expect(footerOwners(layout)).toEqual([priorAssistant.id]); + expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id); + expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]); }); it("recomputes cached history layout when only the live boundary turn changes", () => { @@ -171,7 +171,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByItemId: timingFor(priorAssistant.id), }); liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; @@ -180,7 +180,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByItemId: timingFor(priorAssistant.id), }); expect(footerOwners(first)).toEqual([]); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 82ef98db54..924d9d75ec 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -39,13 +39,13 @@ export interface StreamLayoutInput { isTurnActive: boolean; history: StreamItem[]; liveHead: StreamItem[]; - timingByAssistantId: Map; + timingByItemId: Map; } interface LayoutSegmentInput { strategy: StreamStrategy; items: StreamItem[]; - timingByAssistantId: Map; + timingByItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; hasAuxiliaryFooter: boolean; frameOrder: StreamFrameChildOrder; @@ -57,8 +57,8 @@ interface LayoutSegmentInput { phase: "streaming" | "complete"; } -interface AssistantFooterSource { - item: Extract; +interface TurnFooterSource { + item: StreamItem; items: StreamItem[]; index: number; } @@ -67,23 +67,26 @@ function createTurnFooterHost(input: { item: StreamItem; items: StreamItem[]; index: number; - timingByAssistantId: Map; + timingByItemId: Map; }): TurnFooterHost { return { itemId: input.item.id, items: input.items, - timing: input.timingByAssistantId.get(input.item.id), + timing: input.timingByItemId.get(input.item.id), startIndex: input.index, }; } -function findLatestAssistantInResponse(input: { +function findTurnFooterSource(input: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; boundaryAboveItems?: StreamItem[] | null; boundaryAboveIndex?: number | null; -}): AssistantFooterSource | null { +}): TurnFooterSource | null { + const last = input.items[input.startIndex]; + const toolFallback: TurnFooterSource | null = + last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null; let items = input.items; let index = input.startIndex; let canCrossBoundary = true; @@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: { ) { const item = items[index]; if (!item || (laterItem && !continuesResponse(item, laterItem))) { - return null; + return toolFallback; } if (item.kind === "assistant_message") { return { item, items, index }; @@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: { input.boundaryAboveIndex === null || input.boundaryAboveIndex === undefined ) { - return null; + return toolFallback; } items = input.boundaryAboveItems; @@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return null; } - const assistant = findLatestAssistantInResponse({ + const source = findTurnFooterSource({ strategy: input.strategy, items: footerItems, startIndex: latestIndex, }); - if (!assistant) { + if (!source) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, - timingByAssistantId: input.timingByAssistantId, + item: source.item, + items: source.items, + index: source.index, + timingByItemId: input.timingByItemId, }); } @@ -154,7 +157,7 @@ function resolveCompletedFooter(input: { index: number; item: StreamItem; belowItem: StreamItem | null; - timingByAssistantId: Map; + timingByItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; @@ -163,21 +166,21 @@ function resolveCompletedFooter(input: { return null; } - const assistant = findLatestAssistantInResponse({ + const source = findTurnFooterSource({ strategy: input.strategy, items: input.items, startIndex: input.index, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, }); - if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) { + if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, - timingByAssistantId: input.timingByAssistantId, + item: source.item, + items: source.items, + index: source.index, + timingByItemId: input.timingByItemId, }); } @@ -306,7 +309,7 @@ function layoutSegmentItem( index, item, belowItem, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter: input.auxiliaryTurnFooter, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, @@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { history = layoutSegment({ strategy: input.strategy, items: input.history, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, @@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { const liveHead = layoutSegment({ strategy: input.strategy, items: input.liveHead, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index dec4c489b9..9c6ef3384d 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => { historyStart: 2, }); - expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); - expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ + expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false); + expect(model.turnTiming.byItemId.get("visible-a")).toEqual({ completedAt: tail[3]?.timestamp, durationMs: 1000, }); @@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); - expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); + expect(model.turnTiming.byItemId.has("live-a")).toBe(false); }); it("maps completed turn timing to assistant ids across committed history and live head", () => { @@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(null); - expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ + expect(model.turnTiming.byItemId.get("live-a")).toEqual({ completedAt: head[0]?.timestamp, durationMs: 3000, }); @@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); - expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ + expect(model.turnTiming.byItemId.get("a1")).toEqual({ completedAt: tail[1]?.timestamp, durationMs: 3000, }); @@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => { isMobileBreakpoint: false, }); - expect(model.turnTiming.byAssistantId.size).toBe(0); + expect(model.turnTiming.byItemId.size).toBe(0); }); it.each(["web", "native"] as const)( @@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => { expect(second.history).toBe(first.history); expect(second.segments.historyMounted).toBe(first.segments.historyMounted); - expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId); + expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId); expect(first.history).toHaveLength(20); - expect(first.turnTiming.byAssistantId.has("a29")).toBe(false); - expect(first.turnTiming.byAssistantId.get("a38")).toEqual({ + expect(first.turnTiming.byItemId.has("a29")).toBe(false); + expect(first.turnTiming.byItemId.get("a38")).toEqual({ completedAt: tail[77]?.timestamp, durationMs: 1000, }); diff --git a/packages/app/src/agent-stream/native-scroll-animation.test.ts b/packages/app/src/agent-stream/native-scroll-animation.test.ts new file mode 100644 index 0000000000..947b8c3d1d --- /dev/null +++ b/packages/app/src/agent-stream/native-scroll-animation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; + +describe("native scroll-to-bottom animation", () => { + it("jumps across unmounted history instead of animating through blank rows", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }), + ).toBe(false); + }); + + it("keeps near-bottom movement eligible for animation", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }), + ).toBe(true); + }); + + it("bounds travel by the bottom-side overscan, not the whole window", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }), + ).toBe(true); + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }), + ).toBe(false); + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }), + ).toBe(false); + }); + + it("does not animate before the viewport has been measured", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/agent-stream/native-scroll-animation.ts b/packages/app/src/agent-stream/native-scroll-animation.ts new file mode 100644 index 0000000000..72bc39108c --- /dev/null +++ b/packages/app/src/agent-stream/native-scroll-animation.ts @@ -0,0 +1,10 @@ +export function shouldAnimateNativeScrollToBottom(input: { + offsetY: number; + viewportHeight: number; + windowSize: number; +}): boolean { + // VirtualizedList splits the extra window around the viewport. An animated + // jump farther than the bottom-side overscan crosses unmounted history. + const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight; + return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight; +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index dcb52fd24b..1d4416c359 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -1,8 +1,10 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate"; import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; import type { InstalledPlugin } from "@/plugins/types"; import { applyStreamEvent, + getStreamItemMessageId, hydrateStreamState, type StreamItem, type ToolCallItem, @@ -12,7 +14,8 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { buildAgentStreamRenderModel } from "./model"; -import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; +import { createStreamPresentation } from "./presentation"; +import { createReadingAnchor } from "./reading-anchor"; const runtime = { paseo: {}, @@ -61,9 +64,14 @@ function installProbe( serverId: "host-1", clientBundle, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, + subscribe: () => { + throw new Error("Subscriptions are not used here"); + }, }; } @@ -94,7 +102,11 @@ function pluginData(items: StreamItem[]): unknown[] { return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : [])); } -const presentationOptions = { level: "overview" as const, isTurnActive: true }; +const presentationOptions = { + display: "collapseToolCalls" as const, + expandedTurnHostIds: new Set(), + isTurnActive: true, +}; function streamHarness(transform?: TimelineItemTransform) { let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; @@ -580,3 +592,197 @@ describe("timeline presentation", () => { expect(projectTimelineItems([source], () => [])).toEqual([]); }); }); + +it("opening a turn reveals individual calls rather than another tool group", () => { + const present = createStreamPresentation(); + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + toolCall("two", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + const result = present({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: false, + expandedTurnHostIds: new Set(["prompt"]), + }); + expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id)); + expect(result.groupsByHostId.size).toBe(0); + expect(result.readingRowReplacements.get("tool-two")).toBeUndefined(); +}); + +it.each(["collapseToolCalls", "collapseTurns"] as const)( + "preserves the reader when a prepend extends a group in %s", + (display) => { + const present = createStreamPresentation(); + const anchor = createReadingAnchor(); + const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")]; + const input = { + ...presentationOptions, + transform: undefined, + display, + head: [], + isTurnActive: false, + }; + const partial = present({ ...input, tail: calls.slice(1) }); + anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]); + const completed = present({ ...input, tail: calls }); + const groupId = completed.tail[0]!.id; + expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId); + expect( + anchor.reconcile( + 0, + [{ id: groupId, top: 448, height: 200 }], + false, + completed.readingRowReplacements, + ), + ).toBe(400); + expect(anchor.getRowId()).toBe(groupId); + const timestamp = new Date(); + const folded = present({ + ...input, + display: "collapseTurns", + tail: [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + ...calls, + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ], + }); + // Both the original partial group and its replacement resolve directly to the turn. + expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt"); + expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt"); + const opened = present({ ...input, tail: calls, display: "full" }); + expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined(); + }, +); + +it("presents received agent messages as attributed bubbles with the original turn identity", () => { + const call = toolCall("received", "agent_message"); + if (call.payload.source !== "agent") throw new Error("Expected agent tool"); + call.turnId = "turn"; + call.payload.data.agentMessage = { + event: "message", + sender: { id: "sender", title: "Reviewer" }, + text: "Ready", + }; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail: [call], + head: [], + }); + expect(result.tail[0]).toMatchObject({ + kind: "user_message", + id: call.id, + turnId: "turn", + text: "Ready", + sender: { id: "sender", title: "Reviewer" }, + }); +}); + +it("does not reopen the previous turn while a new run waits for its first row", () => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + for (const item of tail) item.turnId = "completed"; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: true, + activeTurnId: "new", + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); +}); + +// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is +// the completed one; it must stay folded until the new turn's rows arrive. +it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + for (const item of tail) item.turnId = "completed"; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: true, + activeTurnId: null, + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); +}); + +it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => { + const present = createStreamPresentation(); + const timestamp = new Date(); + const thought: StreamItem = { + kind: "thought", + id: "thought", + text: "Consider this", + status: "ready", + timestamp, + }; + const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")]; + for (const item of tail) item.turnId = "run"; + const input = { + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseToolCalls" as const, + isTurnActive: false, + }; + const grouped = present({ ...input, autoExpandReasoning: false }); + expect(grouped.tail).toHaveLength(1); + expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail); + const separate = present({ ...input, autoExpandReasoning: true }); + expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]); + expect(separate.groupsByHostId.has("thought")).toBe(false); + const next = { ...thought, id: "next", turnId: "next-run" }; + const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false }); + expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]); +}); + +it.each([false, true])( + "reveals raw reasoning when opening a turn (Expand thinking %s)", + (autoExpandReasoning) => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + { kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + const input = { + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns" as const, + isTurnActive: false, + autoExpandReasoning, + }; + const present = createStreamPresentation(); + const closed = present(input); + expect(closed.tail.some((item) => item.id === "thought")).toBe(false); + const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) }); + expect(opened.tail).toEqual(tail); + expect(opened.turns.nestedItemIds.has("thought")).toBe(true); + expect(opened.groupsByHostId.size).toBe(0); + }, +); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 4459486a7b..94140ca395 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -1,3 +1,5 @@ +import { isTurnBoundary } from "./turn-membership"; +import type { ChatDisplay } from "@/hooks/use-settings"; import type { TimelineItemTransform } from "@/plugins/timeline/model"; import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; import { @@ -5,23 +7,51 @@ import { projectToolCallDetailLevel, type PreparedToolCallHistory, type ToolCallDetailLevel, + type ToolCallDetailProjection, } from "@/tool-calls/detail-level/projection"; import type { StreamItem, UserMessageItem } from "@/types/stream"; +import { collapseTurns, type TurnCollapse } from "./turn-collapse/model"; +import type { ReadingRowReplacements } from "./reading-anchor"; interface PresentationInput { tail: StreamItem[]; head: StreamItem[]; transform: TimelineItemTransform | undefined; - level: ToolCallDetailLevel; + display: ChatDisplay; + autoExpandReasoning?: boolean; + /** User messages whose collapsed turn the reader opened. */ + expandedTurnHostIds: ReadonlySet; isTurnActive: boolean; + activeTurnId?: string | null; } -/** Find and scrolling address a plugin row by the source it replaces. */ -export function getStreamItemMessageId(item: StreamItem): string { - if (item.kind === "plugin") return item.sourceId ?? item.id; - return item.id; +export interface StreamPresentation extends ToolCallDetailProjection { + turns: Omit; + readingRowReplacements: ReadingRowReplacements; } +function groupedRowHosts( + rows: readonly StreamItem[], + groups: ToolCallDetailProjection["groupsByHostId"], +): ReadonlyMap { + const hosts = new Map(); + for (const row of rows) { + const group = groups.get(row.id); + if (!group) continue; + for (const call of group.run.calls) { + if (call.id !== row.id) hosts.set(call.id, row.id); + } + } + return hosts; +} + +const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ + tail: [], + isLatestTurnOpen: false, + expandedHostIds: new Set(), + getGroupedCalls: () => undefined, +}); + /** Source messages reach plugins before display projection. */ export function createStreamPresentation() { const userMessageCache = new WeakMap(); @@ -45,14 +75,89 @@ export function createStreamPresentation() { let historyTransform: TimelineItemTransform | undefined; let historyRows: StreamItem[] = []; let preparedTail: StreamItem[] | undefined; + let preparedAutoExpandReasoning: boolean | undefined; let preparedLevel: ToolCallDetailLevel | undefined; let preparedHistory: PreparedToolCallHistory | null = null; + let historyGroupHosts: ReadonlyMap = new Map(); + let collapsedSource: StreamItem[] | undefined; + let collapsedIsLatestTurnOpen = false; + let collapsedExpandedHostIds: ReadonlySet | undefined; + let collapsed: TurnCollapse | undefined; function presentRows(item: StreamItem): StreamItem[] { + if (item.kind === "tool_call" && item.payload.source === "agent") { + const message = item.payload.data.agentMessage; + if (message?.event === "message") { + return [ + { + kind: "user_message", + id: item.id, + turnId: item.turnId, + timelineCursor: item.timelineCursor, + timestamp: item.timestamp, + text: message.text, + sender: message.sender, + }, + ]; + } + } return [item.kind === "user_message" ? presentUserMessage(item) : item]; } - return (input: PresentationInput) => { + function collapse( + projection: ToolCallDetailProjection, + input: PresentationInput, + ): Omit { + if (input.display !== "collapseTurns") { + return { ...projection, turns: NO_COLLAPSED_TURNS }; + } + // Live text updates reach the head only, so the folded history is reused until the + // retained rows, the latest turn's liveness, or the reader's expanded turns change. + const last = projection.tail.at(-1) ?? null; + const first = projection.head[0] ?? null; + const activeTurnIsAnother = Boolean( + input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId, + ); + // Without head rows, the latest retained turn is live only while the active run is that + // turn. A run that has not opened yet carries no id and belongs to the next turn. + const isLatestTurnOpen = first + ? !isTurnBoundary(last, first) && !activeTurnIsAnother + : input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId); + if ( + !collapsed || + collapsedSource !== projection.tail || + collapsedIsLatestTurnOpen !== isLatestTurnOpen || + collapsedExpandedHostIds !== input.expandedTurnHostIds + ) { + collapsed = collapseTurns({ + tail: projection.tail, + isLatestTurnOpen, + expandedHostIds: input.expandedTurnHostIds, + getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls, + }); + collapsedSource = projection.tail; + collapsedIsLatestTurnOpen = isLatestTurnOpen; + collapsedExpandedHostIds = input.expandedTurnHostIds; + } + const { tail, ...turns } = collapsed; + if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns }; + // Opening a turn reveals its raw calls; those rows must not become group hosts again. + const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({ + size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length, + get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)), + has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id), + }); + return { + ...projection, + tail, + turns, + groupsByHostId: withoutExpandedRows(projection.groupsByHostId), + historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId), + }; + } + + return (input: PresentationInput): StreamPresentation => { + const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview"; // Retained history is not reprojected or regrouped on each live text update. if (historySource !== input.tail || historyTransform !== input.transform) { historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap( @@ -66,17 +171,48 @@ export function createStreamPresentation() { const blocks = presentRows(item); head.push(...blocks); } - if (preparedTail !== historyRows || preparedLevel !== input.level) { - preparedHistory = prepareToolCallHistory(input.level, historyRows); + if ( + preparedTail !== historyRows || + preparedLevel !== level || + preparedAutoExpandReasoning !== input.autoExpandReasoning + ) { + preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning); + historyGroupHosts = preparedHistory + ? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId) + : new Map(); preparedTail = historyRows; - preparedLevel = input.level; + preparedLevel = level; + preparedAutoExpandReasoning = input.autoExpandReasoning; } - return projectToolCallDetailLevel({ - level: input.level, + const projection = projectToolCallDetailLevel({ + level, tail: historyRows, head, preparedHistory, isTurnActive: input.isTurnActive, }); + const result = collapse(projection, input); + // Only the trailing retained group can absorb head rows. Reuse the retained + // index across streaming updates rather than walking the full history again. + const last = projection.tail.at(-1); + const liveGroupHosts = groupedRowHosts( + last ? [last, ...projection.head] : projection.head, + projection.groupsByHostId, + ); + const retainedGroupHosts = historyGroupHosts; + return { + ...result, + readingRowReplacements: { + get(id) { + if (result.turns.nestedItemIds.has(id)) return undefined; + const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id; + return ( + result.turns.hostIdByHiddenRowId.get(id) ?? + result.turns.hostIdByHiddenRowId.get(groupHost) ?? + (groupHost === id ? undefined : groupHost) + ); + }, + }, + }; }; } diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 74b2bd5bcd..d7d1b2beb4 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { createReadingAnchor } from "./reading-anchor"; describe("reading anchor", () => { + it("pins the folded turn host when a prepend replaces the row being read", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]); + const replacements = new Map([["tool", "prompt"]]); + expect(anchor.getRowId(replacements)).toBe("prompt"); + expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000); + // Projection does not consume the correction; an unmounted host keeps the pin. + expect(anchor.reconcile(0, [], false, replacements)).toBe(0); + expect(anchor.getRowId()).toBe("tool"); + expect( + anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + expect(anchor.getRowId()).toBe("prompt"); + expect( + anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + }); + const rows = [ { id: "above", top: 0, height: 500 }, { id: "reading", top: 500, height: 500 }, diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index 4299f1d9c8..aa915c6ae7 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -6,6 +6,10 @@ interface RowGeometry { // A row must clear the reading line before the next row takes ownership. const READING_POSITION_OFFSET_PX = 8; +export interface ReadingRowReplacements { + get(id: string): string | undefined; +} +const NO_REPLACEMENTS: ReadingRowReplacements = new Map(); // Content coordinates survive user scrolling. Layout commits replace the geometry; // scroll events reuse it, without another DOM measurement pass. @@ -14,10 +18,15 @@ export function createReadingAnchor() { let geometry: readonly RowGeometry[] = []; const readingRow = (scrollTop: number) => geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); - const project = (scrollTop: number, row: Pick | undefined) => - scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); + const getRowId = (replacements = NO_REPLACEMENTS) => + anchor ? (replacements.get(anchor.id) ?? anchor.id) : null; + const project = ( + scrollTop: number, + row: Pick | undefined, + replacements = NO_REPLACEMENTS, + ) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0); return { - getRowId: () => anchor?.id ?? null, + getRowId, getReadingRowId: (scrollTop: number) => readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, project, @@ -28,13 +37,18 @@ export function createReadingAnchor() { const next = readingRow(scrollTop); anchor = next ? { id: next.id, top: next.top } : null; }, - reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + reconcile( + scrollTop: number, + rows: readonly RowGeometry[], + userScrolled = false, + replacements = NO_REPLACEMENTS, + ): number { geometry = rows; - const previous = anchor && rows.find((row) => row.id === anchor?.id); + const previous = anchor && rows.find((row) => row.id === getRowId(replacements)); // The first virtualized commit can precede its mounted range. Keep the // pinned reader until it mounts, rather than adopting an unrelated row. if (anchor && !previous && !userScrolled) return scrollTop; - const correctedTop = project(scrollTop, previous ?? undefined); + const correctedTop = project(scrollTop, previous ?? undefined, replacements); // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. const next = previous && !userScrolled ? previous : readingRow(correctedTop); diff --git a/packages/app/src/agent-stream/render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts index 22d7c5f2f1..59a2f7aed3 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => { ).toBe("assistant-1\n\nassistant-2"); }); - it("collects copy content across adjacent turns without a visible prompt", () => { + it("keeps copied content within the canonical turn without a visible prompt", () => { const chronological: StreamItem[] = [ { ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" }, { ...toolCall("tool", 2), turnId: "turn-2" }, @@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => { items: chronological, startIndex: 2, }), - ).toBe("first turn\n\nsecond turn"); + ).toBe("second turn"); }); it("returns undefined neighbor when index would be out of bounds", () => { diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 66409f23e1..7dc6e8d521 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -23,6 +23,7 @@ import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useKeyboardStreamInset } from "@/keyboard/shift"; +import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; @@ -64,6 +65,7 @@ const historyStartSlotStyle: ViewStyle = { flexShrink: 0, }; const HISTORY_START_SETTLE_FRAMES = 2; +const NATIVE_STREAM_WINDOW_SIZE = 10; function keyExtractor(item: { id: string }): string { return item.id; @@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat programmaticScrollEventBudgetRef.current = 3; flatListRef.current?.scrollToOffset({ offset: 0, - animated, + animated: + animated && + shouldAnimateNativeScrollToBottom({ + offsetY: scrollOffsetYRef.current, + viewportHeight: streamViewportMetricsRef.current.viewportHeight, + windowSize: NATIVE_STREAM_WINDOW_SIZE, + }), }); scrollOffsetYRef.current = 0; streamViewportMetricsRef.current = { @@ -353,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat bottomAnchorController.prepareForStickyViewportChange(); markNativeViewportSettling(); }, + holdReadingPosition: () => bottomAnchorController.detachByUser(), }; viewportRef.current = handle; return () => { @@ -591,11 +600,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} maintainVisibleContentPosition={maintainVisibleContentPosition} - // A history row can contain an entire long reply. Keep synchronous native - // Markdown measurement bounded while FlatList fills the surrounding window. + // Tall Markdown rows exhaust a shallow window during flings. Keep the initial + // render small, then refill a deeper window in batches large enough to keep up. initialNumToRender={2} - maxToRenderPerBatch={2} - windowSize={5} + maxToRenderPerBatch={10} + windowSize={NATIVE_STREAM_WINDOW_SIZE} removeClippedSubviews={false} scrollEnabled={scrollEnabled} showsVerticalScrollIndicator diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 1f61d0a14c..5ad0be03c2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -7,7 +7,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RetainedPanelActivity } from "@/components/retained-panel"; -import type { StreamItem } from "@/types/stream"; +import { reduceStreamUpdate, type StreamItem } from "@/types/stream"; import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy"; import { createWebStreamStrategy } from "./strategy-web"; @@ -131,6 +131,79 @@ describe("createWebStreamStrategy", () => { vi.restoreAllMocks(); }); + it.each(["liveHead", "historyMounted"] as const)( + "keeps one rendered tool row after late prior-turn updates in %s", + (lane) => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: [], liveHead: [] }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: lane === "historyMounted", + hasLiveHead: lane === "liveHead", + }, + renderers: createRenderers(() => {}), + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: () => {}, + onNearHistoryStart: () => true, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }; + let items: StreamItem[] = []; + const updates = [ + { callId: "background", turnId: "old", status: "running" }, + { callId: "exec", turnId: "current", status: "running" }, + { callId: "background", turnId: "old", status: "completed" }, + { callId: "exec", turnId: "current", status: "completed" }, + ] as const; + for (const [index, update] of updates.entries()) { + items = reduceStreamUpdate( + items, + { + type: "timeline", + provider: "codex", + turnId: update.turnId, + item: { + type: "tool_call", + callId: update.callId, + name: "exec_command", + status: update.status, + error: null, + detail: { type: "shell", command: update.callId }, + }, + }, + new Date(index), + ); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { ...input.segments, [lane]: items }, + }), + ), + ); + } + const rows = [...container.querySelectorAll("[data-history-row-id]")]; + expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([ + "agent_tool_turn:old/background", + "agent_tool_turn:current/exec", + ]); + }, + ); + it("mounts virtualized history without recursive row measurement updates", () => { const rowRenderCount = vi.fn(); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 97490c2a17..9dae42bcc0 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; -import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; @@ -44,7 +44,6 @@ import { type HistoryStartSettleScheduler, } from "./history-start-settle-scheduler"; import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find"; -import { getStreamItemMessageId } from "./presentation"; import { useScrollToMessage } from "./use-scroll-to-message.web"; interface CreateWebStreamStrategyInput { @@ -395,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rangeExtractor = useCallback( (range: VirtualRange): number[] => { const anchorIndex = segments.historyVirtualized.findIndex( - (row) => row.id === readingAnchor.getRowId(), + (row) => row.id === readingAnchor.getRowId(props.readingRowReplacements), ); const container = scrollContainerRef.current; // TanStack has computed the new measurements before calling the extractor. @@ -405,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const measurement = virtualizer.measurementsCache[anchorIndex]; const correctedOffset = container && measurement - ? readingAnchor.project(container.scrollTop, { - id: String(measurement.key), - top: measurement.start, - }) + ? readingAnchor.project( + container.scrollTop, + { + id: String(measurement.key), + top: measurement.start, + }, + props.readingRowReplacements, + ) : container?.scrollTop; let visibleRange = range; if ( @@ -429,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]), ].sort((left, right) => left - right); }, - [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements], ); const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); const observeVirtualOffset = useCallback((instance, onOffset) => { @@ -743,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const shouldAnchor = !followOutputRef.current && !isJumpSettling(); if (!shouldAnchor) readingAnchor.reset(); - const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + const correctedTop = readingAnchor.reconcile( + container.scrollTop, + rows, + false, + props.readingRowReplacements, + ); if (!shouldAnchor) { readingAnchor.reset(); return; @@ -1162,6 +1170,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool useEffect(() => { const handle: StreamViewportHandle = { + focus: () => scrollContainerRef.current?.focus({ preventScroll: true }), scrollToBottom: () => { setFollowOutput(true); cancelPendingStickToBottom(); @@ -1173,6 +1182,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, + holdReadingPosition: () => { + cancelPendingStickToBottom(); + setFollowOutput(false); + readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0); + }, scrollToMessage, }; viewportRef.current = handle; @@ -1185,6 +1199,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [ cancelPendingStickToBottom, forceStickToBottom, + readingAnchor, scheduleStickToBottom, scrollToMessage, viewportRef, @@ -1250,6 +1265,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool key={item.id} data-history-row-id={item.id} data-message-id={getStreamItemMessageId(item)} + data-plugin-row={item.kind === "plugin" ? "true" : undefined} style={streamRowStyle} > diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 5f43f8f6b1..364eba1043 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,6 +1,7 @@ import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; +import type { ReadingRowReplacements } from "./reading-anchor"; import { continuesResponse } from "./turn-membership"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { @@ -51,8 +52,12 @@ export interface ScrollToMessageOccurrence { } export interface StreamViewportHandle { + /** Give keyboard navigation back to the scrolling surface on web. */ + focus?: () => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; + /** Stop following output so the rows on screen stay put while rows below them open or close. */ + holdReadingPosition: () => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void; } @@ -74,6 +79,8 @@ export interface StreamRenderInput { segments: StreamRenderSegments; historyRowRevision?: StreamHistoryRowRevision; liveHeadRowRevision?: unknown; + /** Presentation rows replaced by another row after history is folded. */ + readingRowReplacements?: ReadingRowReplacements; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/stream-tick.bench.ts b/packages/app/src/agent-stream/stream-tick.bench.ts index dc1fecb881..b765c441e8 100644 --- a/packages/app/src/agent-stream/stream-tick.bench.ts +++ b/packages/app/src/agent-stream/stream-tick.bench.ts @@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre isTurnActive: true, history: model.history, liveHead: model.segments.liveHead, - timingByAssistantId: model.turnTiming.byAssistantId, + timingByItemId: model.turnTiming.byItemId, }); } } diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts new file mode 100644 index 0000000000..eca6d8757c --- /dev/null +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { collapseTurns, type CollapseTurnsInput } from "./model"; + +const at = new Date("2026-01-01T00:00:00.000Z"); + +function user(id: string): StreamItem { + return { kind: "user_message", id, text: id, timestamp: at }; +} + +function reply(id: string): StreamItem { + return { kind: "assistant_message", id, text: id, timestamp: at }; +} + +function thought(id: string): StreamItem { + return { kind: "thought", id, text: id, timestamp: at, status: "ready" }; +} + +function shell(id: string): ToolCallItem { + return { + kind: "tool_call", + id, + timestamp: at, + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: "ls" }, + }, + }, + }; +} + +function collapse(tail: StreamItem[], options: Partial = {}) { + return collapseTurns({ + tail, + isLatestTurnOpen: false, + expandedHostIds: new Set(), + getGroupedCalls: () => undefined, + ...options, + }); +} + +const ids = (items: StreamItem[]) => items.map((item) => item.id); + +describe("collapseTurns", () => { + it("maps hidden rows only while their turn is closed", () => { + const result = collapse([user("u1"), shell("c1"), reply("final")]); + expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1"); + const expanded = collapse([user("u1"), shell("c1"), reply("final")], { + expandedHostIds: new Set(["u1"]), + }); + expect(expanded.hostIdByHiddenRowId.size).toBe(0); + }); + it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => { + const create = shell("create"); + if (create.payload.source !== "agent") throw new Error("Expected agent tool"); + create.payload.data.name = "mcp__paseo__create_agent"; + create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null }; + const finished = shell("finished"); + if (finished.payload.source !== "agent") throw new Error("Expected agent tool"); + finished.payload.data.name = "agent_notification"; + finished.payload.data.agentMessage = { + event: "finished", + sender: { id: "worker", title: "PR helper" }, + text: "Finished", + }; + const items: StreamItem[] = [ + user("u1"), + create, + finished, + { + kind: "compaction", + id: "compaction", + timestamp: at, + status: "completed", + } satisfies StreamItem, + shell("bash"), + reply("done"), + ].map((item) => Object.assign(item, { turnId: "same-run" })); + const result = collapse(items); + expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]); + expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({ + commandCount: 1, + otherToolCount: 0, + paseoActions: { "Created agent": 1 }, + }); + }); + + it("folds a finished turn's work into its user message and keeps the reply", () => { + const result = collapse([ + user("u1"), + thought("t1"), + reply("early"), + shell("c1"), + shell("c2"), + reply("final"), + ]); + + expect(ids(result.tail)).toEqual(["u1", "final"]); + expect(result.turnsByHostId.get("u1")).toEqual({ + hostId: "u1", + expanded: false, + summary: { + tools: expect.objectContaining({ commandCount: 2 }), + }, + }); + expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1"); + }); + + it("shows an expanded turn's work as nested rows between the prompt and the reply", () => { + const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], { + expandedHostIds: new Set(["u1"]), + }); + + expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]); + expect([...result.nestedItemIds]).toEqual(["t1", "c1"]); + expect(result.turnsByHostId.get("u1")?.expanded).toBe(true); + }); + + it("leaves the latest turn alone while it is still producing output", () => { + const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")]; + + const result = collapse(tail, { isLatestTurnOpen: true }); + + expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]); + expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + }); + + it("keeps rows after the reply, and turns with no work before the reply, as they are", () => { + const result = collapse([ + user("u1"), + shell("c1"), + reply("r1"), + shell("after"), + user("u2"), + reply("r2"), + ]); + + expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]); + expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + }); + + it("does not fold a turn that only thought before its reply", () => { + const tail = [user("u1"), thought("t1"), reply("r1")]; + + const result = collapse(tail); + + expect(result.tail).toBe(tail); + expect(result.turnsByHostId.size).toBe(0); + }); + + it("does not fold a turn without a reply or whose prompt is above the loaded history", () => { + const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")]; + + const result = collapse(tail); + + expect(result.tail).toBe(tail); + expect(result.turnsByHostId.size).toBe(0); + }); + + it("keeps a completed response folded when a promptless canonical turn starts", () => { + const first = [user("u1"), shell("c1"), reply("r1")].map((item) => + Object.assign(item, { turnId: "first" }), + ); + const next = { ...shell("heartbeat"), turnId: "second" }; + const result = collapse([...first, next], { isLatestTurnOpen: true }); + expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]); + expect(result.turnsByHostId.has("u1")).toBe(true); + }); + + it("keeps mid-turn messages visible when work is folded", () => { + const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")]; + for (const item of tail) item.turnId = "same-turn"; + const result = collapse(tail); + expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]); + expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false); + }); + + it("reveals every raw call inside an expanded turn", () => { + const calls = [shell("one"), shell("two")]; + const result = collapse([user("prompt"), calls[0]!, reply("done")], { + expandedHostIds: new Set(["prompt"]), + getGroupedCalls: (id) => (id === "one" ? calls : undefined), + }); + expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]); + expect([...result.nestedItemIds]).toEqual(["one", "two"]); + }); + + it("counts every call a tool-call group row stands for", () => { + const group = [shell("c1"), shell("c2"), shell("c3")]; + const result = collapse([user("u1"), group[0]!, reply("final")], { + getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined), + }); + + expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({ + tools: { commandCount: 3 }, + }); + }); +}); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts new file mode 100644 index 0000000000..a5985e600f --- /dev/null +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -0,0 +1,159 @@ +import { isTurnBoundary, isTurnTrigger } from "../turn-membership"; +import { + getStreamItemMessageId, + type StreamItem, + type ThoughtItem, + type ToolCallItem, +} from "@/types/stream"; +import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model"; + +export interface TurnSummary { + tools: OverviewSummary; +} + +export interface CollapsedTurn { + /** The input or first loaded row that carries the turn's summary line. */ + hostId: string; + expanded: boolean; + summary: TurnSummary; +} + +export interface TurnCollapse { + tail: StreamItem[]; + turnsByHostId: ReadonlyMap; + /** Rows of expanded turns, which render inside the turn's indent rail. */ + nestedItemIds: ReadonlySet; + /** The turn a folded message belongs to, for surfaces that must reveal it. */ + hostIdByFoldedMessageId: ReadonlyMap; + /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ + hostIdByHiddenRowId: ReadonlyMap; +} + +export interface CollapseTurnsInput { + tail: StreamItem[]; + /** The latest turn is still producing output, so it stays unfolded. */ + isLatestTurnOpen: boolean; + expandedHostIds: ReadonlySet; + /** The calls a tool-call group row stands for; undefined for a single call. */ + getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined; +} + +const EMPTY_TURNS: ReadonlyMap = new Map(); +const EMPTY_IDS: ReadonlySet = new Set(); +const EMPTY_HOSTS: ReadonlyMap = new Map(); + +function summarizeTurn( + items: readonly StreamItem[], + getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], +): TurnSummary { + const calls: (ToolCallItem | ThoughtItem)[] = []; + for (const item of items) { + if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item)) + calls.push(...(getGroupedCalls(item.id) ?? [item])); + } + return { tools: summarizeToolCalls(calls) }; +} + +function hasWork(summary: TurnSummary): boolean { + const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; + return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0); +} + +function staysVisibleWhenCollapsed(item: StreamItem): boolean { + return isTurnTrigger(item) || item.kind === "compaction"; +} + +function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { + for (let index = end - 1; index >= start; index -= 1) { + if (tail[index]?.kind === "assistant_message") return index; + } + return -1; +} + +/** + * Folds each answered turn into its user message: everything the agent did before its last + * message collapses into one summary line, and that message stays in the chat. A turn folds + * only once it is complete, its user message is loaded, and it sent messages or called tools + * before its reply. Reasoning is revealed only when the turn opens. + */ +export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { + const { tail } = input; + const output: StreamItem[] = []; + const turnsByHostId = new Map(); + const nestedItemIds = new Set(); + const hostIdByFoldedMessageId = new Map(); + const hostIdByHiddenRowId = new Map(); + + let runStart = 0; + const flushRun = (runEnd: number) => { + const host = tail[runStart - 1]; + const isClosed = runEnd < tail.length || !input.isLatestTurnOpen; + const replyIndex = findLastAssistantIndex(tail, runStart, runEnd); + if ( + !host || + (host.kind !== "user_message" && host.kind !== "tool_call") || + !isClosed || + replyIndex <= runStart + ) { + output.push(...tail.slice(runStart, runEnd)); + return; + } + const folded = tail.slice(runStart, replyIndex); + const summary = summarizeTurn(folded, input.getGroupedCalls); + if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) { + output.push(...tail.slice(runStart, runEnd)); + return; + } + const expanded = input.expandedHostIds.has(host.id); + turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); + const revealed = expanded + ? folded.flatMap((item) => + item.kind === "tool_call" || item.kind === "thought" + ? [...(input.getGroupedCalls(item.id) ?? [item])] + : [item], + ) + : folded; + for (const item of revealed) { + if (staysVisibleWhenCollapsed(item)) continue; + hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id); + if (expanded) nestedItemIds.add(item.id); + else hostIdByHiddenRowId.set(item.id, host.id); + } + output.push( + ...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))), + ); + output.push(...tail.slice(replyIndex, runEnd)); + }; + + for (let index = 0; index < tail.length; index += 1) { + if ( + !(index === 0 && isTurnTrigger(tail[index]!)) && + !isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null) + ) + continue; + flushRun(index); + output.push(tail[index]!); + runStart = index + 1; + } + flushRun(tail.length); + + if (turnsByHostId.size === 0) { + return { + tail, + turnsByHostId: EMPTY_TURNS, + nestedItemIds: EMPTY_IDS, + hostIdByFoldedMessageId: EMPTY_HOSTS, + hostIdByHiddenRowId: EMPTY_HOSTS, + }; + } + return { + tail: + output.length === tail.length && output.every((item, index) => item === tail[index]) + ? tail + : output, + turnsByHostId, + nestedItemIds, + hostIdByFoldedMessageId, + hostIdByHiddenRowId, + }; +} diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx new file mode 100644 index 0000000000..191ac706d5 --- /dev/null +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -0,0 +1,36 @@ +import { memo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Summary } from "lucide-react-native"; +import { ExpandableBadge } from "@/components/message"; +import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label"; +import type { CollapsedTurn } from "./model"; + +/** + * The row a collapsed turn leaves under its user message, styled like a tool-call group. + * Pressing it opens the turn's rows beneath it. + */ +export const TurnSummaryLine = memo(function TurnSummaryLine({ + turn, + onExpandedChange, +}: { + turn: CollapsedTurn; + onExpandedChange: (hostId: string, expanded: boolean) => void; +}) { + const { t } = useTranslation(); + const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); + const toggle = useCallback( + () => onExpandedChange(turn.hostId, !turn.expanded), + [onExpandedChange, turn.expanded, turn.hostId], + ); + + return ( + + ); +}); diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index acbd920b81..5d04f9eca9 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -197,7 +197,7 @@ function CompletedTurnFooter({ return ( ["layout"]): str } describe("canonical turn membership", () => { + it("uses trigger fallback for missing IDs without pretending to recover batching", () => { + const previous = assistant("reply", 1, "first"); + const trigger = runningTool("heartbeat", 2, "first"); + if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent") + throw new Error("Expected agent tool"); + trigger.payload.data.name = "heartbeat"; + expect(continuesTurn(previous, trigger)).toBe(true); + delete trigger.turnId; + expect(continuesTurn(previous, trigger)).toBe(false); + trigger.payload.data.name = "agent_message"; + trigger.payload.data.agentMessage = { + event: "finished", + sender: { id: "worker" }, + text: "Done", + }; + expect(continuesTurn(previous, trigger)).toBe(false); + }); + it("stops tagged traversal at every mismatching turn ID", () => { expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe( false, @@ -76,7 +94,7 @@ describe("canonical turn membership", () => { ); }); - it("renders adjacent turns without visible prompts as one response", () => { + it("renders a separate footer for each canonical turn without visible prompts", () => { const items = [ user("prompt", 1, "turn-1"), assistant("first reply", 2, "turn-1"), @@ -88,7 +106,11 @@ describe("canonical turn membership", () => { const completed = layoutFor(items, false); - expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]); + expect(completedFooterIds(completed.layout)).toEqual([ + "first reply", + "heartbeat-1-reply", + "heartbeat-2-reply", + ]); expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({ completedAt: at(6), durationMs: null, @@ -123,7 +145,7 @@ describe("canonical turn membership", () => { const completedItems = [...build(turnId), assistant("done", 9, turnId)]; const completed = layoutFor(completedItems, false); expect(completedFooterIds(completed.layout)).toEqual(["done"]); - expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ + expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({ completedAt: at(9), durationMs: 8000, }); diff --git a/packages/app/src/agent-stream/turn-membership.ts b/packages/app/src/agent-stream/turn-membership.ts index 414d583305..9650c12ff1 100644 --- a/packages/app/src/agent-stream/turn-membership.ts +++ b/packages/app/src/agent-stream/turn-membership.ts @@ -1,24 +1,21 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { StreamItem } from "@/types/stream"; /** * Canonical turn IDs take precedence. Timelines without them retain the legacy - * user-message boundary rule so old daemons and persisted rows need no rewrite. + * trigger boundary rule; missing IDs cannot reconstruct historical batching. */ export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean { if (!previous || !next) return false; - if (previous.turnId !== undefined && next.turnId !== undefined) { - return previous.turnId === next.turnId; - } - return next.kind !== "user_message"; + return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next)); } /** - * A visible response can span multiple canonical turns when their prompts are - * system-injected and therefore absent from the Paseo timeline. + * Every canonical turn owns its response, including system-triggered runs. */ export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean { if (!previous || !next) return false; - return continuesTurn(previous, next) || next.kind !== "user_message"; + return continuesTurn(previous, next); } export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { @@ -37,3 +34,12 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo export function belongsToTurn(item: StreamItem, turnId: string | null): boolean { return turnId !== null && item.turnId === turnId; } + +/** Inputs that start a turn when older history has no canonical turn IDs. */ +export function isTurnTrigger(item: StreamItem): boolean { + return isTimelineTurnTrigger( + item.kind === "tool_call" && item.payload.source === "agent" + ? { type: item.kind, ...item.payload.data } + : { type: item.kind }, + ); +} diff --git a/packages/app/src/agent-stream/use-agent-stream-tail.ts b/packages/app/src/agent-stream/use-agent-stream-tail.ts new file mode 100644 index 0000000000..cd26faff47 --- /dev/null +++ b/packages/app/src/agent-stream/use-agent-stream-tail.ts @@ -0,0 +1,12 @@ +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useSessionStore } from "@/stores/session-store"; + +// The view retains its last visible transcript. Selecting a stable value here +// also prevents incoming chunks from scheduling React work in a hidden panel. +// Revealing the panel reads the current tail directly from the model. +export function useAgentStreamTail(serverId: string, agentId: string | undefined) { + const active = useRetainedPanelActive(); + return useSessionStore((state) => + active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined, + ); +} diff --git a/packages/app/src/agent-stream/use-scroll-to-message.web.ts b/packages/app/src/agent-stream/use-scroll-to-message.web.ts index 6de7a3296c..e593f1d323 100644 --- a/packages/app/src/agent-stream/use-scroll-to-message.web.ts +++ b/packages/app/src/agent-stream/use-scroll-to-message.web.ts @@ -1,8 +1,7 @@ import type React from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { Virtualizer } from "@tanstack/react-virtual"; -import type { StreamItem } from "@/types/stream"; -import { getStreamItemMessageId } from "./presentation"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle"; import type { ScrollToMessageOccurrence } from "./strategy"; diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts index 9874158dcc..340ecb4da4 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window"; -import { getStreamItemMessageId } from "./presentation"; // Callers reveal a message, not a row: an assistant message is several block rows and // the window has to open at the first of them. diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c9b44ed679..72923b4212 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,7 +41,7 @@ import { type InlinePathTarget, } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; -import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { PendingMessageSubmission } from "@/composer/submission/model"; import type { TurnPresentation } from "@/timeline/turn-liveness"; import type { PendingPermission } from "@/types/shared"; @@ -61,9 +61,16 @@ import { returnToTimelineTail } from "./timeline-tail-navigation"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; +import { + ToolCallExpansionContext, + useAgentToolCallExpansion, + useExpandedToolCallGroups, +} from "@/tool-calls/expansion-context"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; +import { createStreamPresentation } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; +import { IndentRail } from "@/components/indent-rail"; +import { TurnSummaryLine } from "./turn-collapse/summary-line"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; @@ -88,6 +95,7 @@ import { import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links"; +import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder"; import { createWorkspaceFileTabTarget, type WorkspaceFileLocation, @@ -148,9 +156,16 @@ function renderPendingPermissionsNode(input: { ); } +/** + * Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the + * turn's last row. The rail runs through the gaps between them so the turn reads as one block. + */ +type TurnIndent = "none" | "openedHost" | "nested" | "lastNested"; + function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; + turnIndent: TurnIndent; strategy: TurnContentStrategy; supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; @@ -171,7 +186,7 @@ function renderStreamItemWithTurnFooter(input: { /> ) : null; const content = ( - + {input.content} ); @@ -333,7 +348,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); - const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); + const chatDisplay = useSettings((settings) => settings.chatDisplay); const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled); const contentMaxWidth = useSettings(resolveContentMaxWidth); const viewportRef = useRef(null); @@ -354,9 +369,9 @@ const AgentStreamViewComponent = forwardRef>( new Set(), ); - const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState>( - new Set(), - ); + const toolCallExpansion = useAgentToolCallExpansion(agentId); + const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion); + const [expandedTurnHostIds, setExpandedTurnHostIds] = useState>(new Set()); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? context.serverId ?? ""; @@ -420,7 +435,7 @@ const AgentStreamViewComponent = forwardRef { setIsNearBottom(true); setExpandedInlineToolCallIds(new Set()); - setExpandedToolCallGroupIds(new Set()); + setExpandedTurnHostIds(new Set()); }, [agentId]); const isActive = useRetainedPanelActive(); @@ -445,23 +460,21 @@ const AgentStreamViewComponent = forwardRef createInlinePathOpener({ workspaceRoot, + resolveRelativeFiles: true, client, openFile: openInlineFile, openDirectory: openInlineDirectory, + openOutsideDirectory: openInlineOutsideDirectory, }), - [workspaceRoot, client, openInlineFile, openInlineDirectory], + [workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory], ); useEffect(() => { if (!isActive) inlinePathOpener.cancel(); @@ -517,16 +530,22 @@ const AgentStreamViewComponent = forwardRef { - setExpandedToolCallGroupIds((previous) => { + const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => { + // A turn opens below its summary line; keep that line under the reader's pointer. + viewportRef.current?.holdReadingPosition(); + setExpandedTurnHostIds((previous) => { + if (previous.has(hostId) === expanded) return previous; const next = new Set(previous); if (expanded) { - next.add(groupId); + next.add(hostId); } else { - next.delete(groupId); + next.delete(hostId); } return next; }); }, []); + // Chat find reveals a match inside a collapsed turn by opening the turn first. + const revealLoadedMessage = useStableEvent((messageId: string): boolean => { + const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId); + if (hostId && !expandedTurnHostIds.has(hostId)) { + setTurnExpanded(hostId, true); + return true; + } + return revealLoadedHistory(messageId); + }); + // Read through stable events so a collapse rerun does not change the renderer identity; + // rows whose turn state changed are revised through `historyRowRevision`. + const getCollapsedTurn = useStableEvent((hostId: string) => + presentation.turns.turnsByHostId.get(hostId), + ); + const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => { + const { nestedItemIds } = presentation.turns; + if (nestedItemIds.has(layoutItem.item.id)) { + const below = layoutItem.belowItem; + return below && nestedItemIds.has(below.id) ? "nested" : "lastNested"; + } + return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none"; + }); + + const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded; const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { - return ( + const collapsedTurn = getCollapsedTurn(item.id); + const message = ( ); + if (!collapsedTurn) { + return message; + } + return ( + <> + {message} + + + ); }, - [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], + [ + context.capabilities, + agentId, + client, + getCollapsedTurn, + pendingClientMessageIds, + resolvedServerId, + setTurnExpanded, + ], ); const renderAssistantMessageItem = useCallback( @@ -744,7 +809,7 @@ const AgentStreamViewComponent = forwardRef, isLastInSequence: boolean, - maxDetailHeight?: number, + options: { maxDetailHeight?: number; standalone?: boolean } = {}, ) => { const { payload } = item; @@ -771,11 +836,13 @@ const AgentStreamViewComponent = forwardRef ); } @@ -791,11 +858,12 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], + [context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile], ); // Read through a stable event so live group updates do not change the renderer identity @@ -804,12 +872,17 @@ const AgentStreamViewComponent = forwardRef) => { + ( + layoutItem: StreamLayoutItem, + item: Extract, + ) => { const group = getToolCallGroup(item.id); if (!group) { - return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); + return item.kind === "thought" + ? renderThoughtItem(layoutItem, item) + : renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } - const expanded = expandedToolCallGroupIds.has(group.run.id); + const expanded = expandedToolCallGroups.has(group.run.id); return ( ( - {renderSingleToolCallItem( - call, - index === group.run.calls.length - 1, - GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + {call.kind === "thought" ? ( + // Reasoning is grouped only while Expand thinking is off, so an opened + // group shows each thought as a closed row like any other call. + + ) : ( + renderSingleToolCallItem(call, index === group.run.calls.length - 1, { + maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + standalone: false, + }) )} )) @@ -832,7 +918,9 @@ const AgentStreamViewComponent = forwardRef + {renderToolCallItem(layoutItem, item)} + {getCollapsedTurn(item.id) ? ( + + ) : null} + + ); case "notification": return ; @@ -883,8 +981,9 @@ const AgentStreamViewComponent = forwardRef ({ contentById: presentation.historyGroupUpdatesByHostId, - displayStateById: expandedToolCallGroupIds, + displayStateById: { + // An opened lone call also marks its future group open; that row stays unrevised + // until a group actually forms there. + has: (id: string) => + (expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) || + expandedTurnHostIds.has(id) || + nestedItemIds.has(id), + }, globalDisplayState: isMobile, }), - [expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId], + [ + expandedToolCallGroups, + getToolCallGroup, + expandedTurnHostIds, + isMobile, + nestedItemIds, + presentation.historyGroupUpdatesByHostId, + ], ); const findItems = useMemo( @@ -1084,35 +1200,38 @@ const AgentStreamViewComponent = forwardRef - {streamRenderStrategy.render({ - agentId, - segments: renderModel.segments, - historyRowRevision, - liveHeadRowRevision: expandedToolCallGroupIds, - boundary, - renderers, - listEmptyComponent, - viewportRef, - routeBottomAnchorRequest, - isAuthoritativeHistoryReady, - onNearBottomChange: setIsNearBottom, - onReadingPositionChange: handleReadingPositionChange, - onNearHistoryStart: loadOlder, - isLoadingOlderHistory: isLoadingOlder, - hasOlderHistory: hasOlder, - olderHistoryProgressKey: progressKey, - scrollEnabled: streamScrollEnabled, - listStyle: stylesheet.list, - baseListContentContainerStyle: stylesheet.listContentContainer, - forwardListContentContainerStyle: stylesheet.forwardListContentContainer, - contentMaxWidth, - })} + + {streamRenderStrategy.render({ + readingRowReplacements: presentation.readingRowReplacements, + agentId, + segments: renderModel.segments, + historyRowRevision, + liveHeadRowRevision: expandedToolCallGroups, + boundary, + renderers, + listEmptyComponent, + viewportRef, + routeBottomAnchorRequest, + isAuthoritativeHistoryReady, + onNearBottomChange: setIsNearBottom, + onReadingPositionChange: handleReadingPositionChange, + onNearHistoryStart: loadOlder, + isLoadingOlderHistory: isLoadingOlder, + hasOlderHistory: hasOlder, + olderHistoryProgressKey: progressKey, + scrollEnabled: streamScrollEnabled, + listStyle: stylesheet.list, + baseListContentContainerStyle: stylesheet.listContentContainer, + forwardListContentContainerStyle: stylesheet.forwardListContentContainer, + contentMaxWidth, + })} + ["status"]; isLastInSequence: boolean; defaultExpanded: boolean; + standalone?: boolean; } // Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text. @@ -1289,6 +1415,7 @@ function ThoughtSlot({ status, isLastInSequence, defaultExpanded, + standalone, }: ThoughtSlotProps) { const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming"); return ( @@ -1300,6 +1427,7 @@ function ThoughtSlot({ status={status === "ready" ? "completed" : "executing"} isLastInSequence={isLastInSequence} defaultExpanded={defaultExpanded} + standalone={standalone} forceInline={defaultExpanded} /> ); @@ -1314,7 +1442,9 @@ function ToolCallSlot({ (expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded), [onInlineDetailsExpandedChangeByItemId, itemId], ); - return ; + return ( + + ); } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -1761,15 +1891,40 @@ const permissionStyles = StyleSheet.create((theme) => ({ })); interface StreamItemWrapperProps { - itemId: string; gapBelow: number; + turnIndent: TurnIndent; children: ReactNode; } -function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) { +function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) { const wrapperStyle = useMemo( - () => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }], - [gapBelow], + () => [ + stylesheet.streamItemWrapper, + { marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow }, + ], + [gapBelow, turnIndent], ); - return {children}; + const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]); + switch (turnIndent) { + case "none": + case "openedHost": + return ( + + {children} + {turnIndent === "openedHost" ? : null} + + ); + case "nested": + return ( + + {children} + + ); + case "lastNested": + return ( + + {children} + + ); + } } diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts index 31107789e2..2d1f1b5b75 100644 --- a/packages/app/src/agent-stream/web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] { } describe("findMountedWindowStart", () => { + it("mounts complete autonomous runs and keeps same-run steers together", () => { + const items = [ + { ...userMessage("human", 1), turnId: "first" }, + { ...assistantMessage("reply", 2), turnId: "first" }, + { ...toolCall("wake", 3), turnId: "autonomous" }, + { ...userMessage("steer", 4), turnId: "autonomous" }, + { ...assistantMessage("done", 5), turnId: "autonomous" }, + ]; + expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2); + }); + it("keeps all items mounted when the chat is below the threshold", () => { const items = [userMessage("u1", 1), assistantMessage("a1", 2)]; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2d6525b27f..a1e3ac3411 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,10 +1,14 @@ +import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; import { ComposerEditingSessionsProvider } from "@/composer/editing-session"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; import { LucideProvider } from "lucide-react-native"; import * as Linking from "expo-linking"; -import * as Notifications from "expo-notifications"; import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router"; import { createContext, @@ -13,7 +17,6 @@ import { useContext, useEffect, useMemo, - useRef, useState, useSyncExternalStore, } from "react"; @@ -41,18 +44,14 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; import { WorkspaceRenameHost } from "@/components/workspace-rename-host"; -import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; +import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet"; -import { - getIsElectronRuntime, - HEADER_INNER_HEIGHT, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { canDesktopAppSidebarShare, resolveDesktopAppChromeLayout, @@ -74,6 +73,7 @@ import { type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; +import { useSettingsRouteEntry } from "@/navigation/settings-presentation"; import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; @@ -90,12 +90,10 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; -import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar"; import { KeyboardShiftProvider } from "@/keyboard/shift"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useOpenProject } from "@/hooks/use-open-project"; import { useAppSettings } from "@/hooks/use-settings"; -import { useStableEvent } from "@/hooks/use-stable-event"; import { useOpenAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider"; import { @@ -130,14 +128,9 @@ import { parseHostWorkspaceRouteFromPathname, parseServerIdFromPathname, } from "@/utils/host-routes"; -import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; import { PluginCatalogSync } from "@/plugins"; -import { - ensureOsNotificationPermission, - WEB_NOTIFICATION_CLICK_EVENT, - type WebNotificationClickDetail, -} from "@/utils/os-notifications"; +import { NotificationOpenRouter } from "@/notification-open/react"; +import { ensureOsNotificationPermission } from "@/utils/os-notifications"; polyfillCrypto(); @@ -157,114 +150,6 @@ const HostRuntimeBootstrapContext = createContext({ startupBlocker: { kind: "none" }, }); -function PushNotificationRouter() { - const router = useRouter(); - const lastHandledIdRef = useRef(null); - const openNotification = useStableEvent((data: Record | undefined) => { - const target = resolveNotificationTarget(data); - const serverId = target.serverId; - const workspaceId = target.workspaceId; - const agentId = target.agentId; - if (serverId && workspaceId && agentId) { - navigateToAgent({ serverId, workspaceId, agentId, pin: true }); - return; - } - - router.navigate(buildNotificationRoute(data)); - }); - - useEffect(() => { - if (isWeb) { - let removeDesktopNotificationListener: (() => void) | null = null; - let cancelled = false; - - if (getIsElectronRuntime()) { - void ensureOsNotificationPermission(); - - const unlistenResult = getDesktopHost()?.events?.on?.( - "notification-click", - (payload: unknown) => { - const data = - typeof payload === "object" && - payload !== null && - "data" in payload && - typeof (payload as { data?: unknown }).data === "object" && - (payload as { data?: unknown }).data !== null - ? (payload as { data: Record }).data - : undefined; - openNotification(data); - }, - ); - - void Promise.resolve(unlistenResult).then((unlisten) => { - if (typeof unlisten !== "function") { - return; - } - if (cancelled) { - unlisten(); - return; - } - removeDesktopNotificationListener = unlisten; - return; - }); - } - - const openFromWebClick = (event: Event) => { - const customEvent = event as CustomEvent; - event.preventDefault(); - openNotification(customEvent.detail?.data); - }; - - window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); - - return () => { - cancelled = true; - removeDesktopNotificationListener?.(); - window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); - }; - } - - Notifications.setNotificationHandler({ - handleNotification: async () => ({ - // When the app is open, don't show OS banners. - shouldShowAlert: false, - shouldShowBanner: false, - shouldShowList: false, - shouldPlaySound: false, - shouldSetBadge: false, - }), - }); - - const openFromResponse = (response: Notifications.NotificationResponse) => { - const identifier = response.notification.request.identifier; - if (lastHandledIdRef.current === identifier) { - return; - } - lastHandledIdRef.current = identifier; - - const data = response.notification.request.content.data as - | Record - | undefined; - openNotification(data); - }; - - const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse); - - void Notifications.getLastNotificationResponseAsync().then((response) => { - if (response) { - openFromResponse(response); - } - return; - }); - - return () => { - subscription.remove(); - }; - }, [openNotification]); - - return null; -} - function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { const client = useHostRuntimeClient(daemon.serverId); @@ -468,17 +353,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); - const { width: viewportWidth } = useWindowDimensions(); + const { width: windowWidth } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const viewportWidth = windowWidth - insets.left - insets.right; const cycleTheme = useCallback(() => { void updateSettings({ theme: getNextThemePreference(settings.theme) }); }, [settings.theme, updateSettings]); const isCompactLayout = useIsCompactFormFactor(); - const explorerSidebarPresentation = resolveExplorerSidebarPresentation({ - isCompact: isCompactLayout, - }); - const usesCompactExplorerHost = explorerSidebarPresentation !== "pane"; useCompactWebViewportZoomLock(isCompactLayout); const pathname = usePathname(); const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null; @@ -516,7 +399,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon useActiveWorktreeNewAction(); useGlobalNewWorkspaceAction(); + const activeWorkspace = useActiveWorkspaceSelection(); + const explorerVisible = useIsExplorerSidebarOpen({ + isCompact: isCompactLayout, + workspaceKey: activeWorkspace + ? `${activeWorkspace.serverId}:${activeWorkspace.workspaceId}` + : null, + }); const appContentMinimumWidth = resolveDesktopAppContentMinimum({ + explorerVisible: isWorkspaceRoute && explorerVisible, isSettingsRoute: pathname.includes("/settings"), }); const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled; @@ -548,28 +439,25 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon if (isWeb) { themedSidebarChrome = {sidebarChrome}; } + const workspaceChromeStyle = useMemo( + () => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }], + [insets.left, insets.right, isCompactLayout], + ); const workspaceChrome = ( - + {!isCompactLayout ? ( {themedSidebarChrome} ) : null} - {usesCompactExplorerHost ? ( - + - - {children} - - - ) : ( - {children} - )} + ); @@ -578,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const surface = ( {workspaceChrome} + {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( @@ -620,15 +509,27 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon ); - const content = isCompactLayout ? ( - {surface} - ) : ( - surface + return ( + + {surface} + ); +} +function AppContainerLifetime({ + children, + compactGesturesEnabled, +}: { + children: ReactNode; + compactGesturesEnabled: boolean; +}) { return ( - {content} + + + {children} + + ); } @@ -662,10 +563,15 @@ function MobileGestureWrapper({ chromeEnabled: boolean; }) { const openGesture = useOpenAgentListGesture(chromeEnabled); + const isCompactLayout = useIsCompactFormFactor(); return ( - + {children} @@ -856,6 +762,7 @@ function OpenProjectListener() { } function AppWithSidebar({ children }: { children: ReactNode }) { + useSettingsRouteEntry(); const pathname = usePathname(); const hosts = useHosts(); const storeReady = useStoreReady(); @@ -900,7 +807,6 @@ function RootStack() { - @@ -923,6 +829,9 @@ function WorkspaceRouteNavigationBridge() { } function AppShell() { + useEffect(() => { + if (getDesktopHost()) void ensureOsNotificationPermission(); + }, []); return ( @@ -937,10 +846,15 @@ function AppShell() { ); } +function NotificationOpenBridge() { + const { startupBlocker } = useHostRuntimeBootstrapState(); + return ; +} + function RuntimeProviders({ children }: { children: ReactNode }) { return ( - + {children} @@ -977,11 +891,13 @@ function RootAppTree() { - - - - - + + + + + + + diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 1a47093e2c..df16818239 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -10,6 +10,8 @@ const HOST_STACK_SCREEN_OPTIONS = { animation: "none" as const, }; +const SCREEN_OWNED_APPEARANCE = ["workspace/[workspaceId]/index"] as const; + const AGENT_SCREEN_OPTIONS = { gestureEnabled: false }; export default function HostRouteLayout() { @@ -34,7 +36,10 @@ function KnownHostRoute() { } const stack = ( - + diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..551e3b604d 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -4,6 +4,9 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo import { useFetchQuery } from "@/data/query"; import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; +import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks"; +import { useDirectoryDemand } from "@/hooks/use-directory-demand"; +import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; @@ -83,7 +86,12 @@ function HostAgentReadyRouteContent() { lookupQuery.isSuccess, shouldLookupAgent, ]); + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const hasHydratedLayout = useWorkspaceLayoutStoreHydrated(); + useDirectoryDemand(serverId); const resolution = resolveAgentRoute({ + hasHydratedWorkspaces, + hasHydratedLayout, serverId, agentId, cachedWorkspaceId: agentWorkspaceId, @@ -106,7 +114,7 @@ function HostAgentReadyRouteContent() { handledNavigationRef.current = navigationKey; if (resolution.kind === "resolved") { - navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId }); + navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true }); return; } router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId)); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 7dc11b08f1..2ea41196aa 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useNavigation } from "@react-navigation/native"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useNavigation } from "expo-router/react-navigation"; import { StyleSheet, View } from "react-native"; -import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { RetainedPanel } from "@/components/retained-panel"; import { @@ -9,9 +9,10 @@ import { useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; +import { useDirectoryDemand } from "@/hooks/use-directory-demand"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; -import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { areRetainedWorkspaceSelectionListsEqual, areWorkspaceSelectionsEqual, @@ -33,7 +34,7 @@ import { replaceBrowserRouteWithCanonicalHostWorkspaceRoute, stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit, } from "@/utils/host-route-browser"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; import { isNative, isWeb } from "@/constants/platform"; import { RenderProfile } from "@/utils/render-profiler"; @@ -64,6 +65,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge return { kind: "draft", draftId: openIntent.draftId }; } +// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22. +// Bookmarks and older clients still send ?open=; internal navigation uses pending model state. function stripOpenSearchParamFromBrowserUrl() { if (!isWeb || typeof window === "undefined") { return; @@ -81,7 +84,9 @@ function clearConsumedOpenIntent(input: { }) { input.navigation.setParams({ open: undefined }); if (isWeb) { - stripOpenSearchParamFromBrowserUrl(); + // Expo commits the incoming browser URL after setParams. Strip the consumed legacy input + // after that commit so it cannot be restored into the address bar and replayed on reload. + window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0); } } @@ -95,15 +100,9 @@ export default function HostWorkspaceIndexRoute() { function HostWorkspaceRouteContent() { const navigation = useNavigation(); - const rootNavigationState = useRootNavigationState(); - const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); - const consumedIntentRef = useRef(null); - const [intentConsumed, setIntentConsumed] = useState(false); const params = useLocalSearchParams<{ serverId?: string | string[]; workspaceId?: string | string[]; - }>(); - const globalParams = useGlobalSearchParams<{ open?: string | string[]; }>(); const serverId = getParamValue(params.serverId); @@ -111,14 +110,11 @@ function HostWorkspaceRouteContent() { const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") : ""; - const openValue = getParamValue(globalParams.open); - const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); - const workspaceExists = useWorkspaceExists(serverId, workspaceId); - const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); - const isAgentOpenIntent = openIntent?.kind === "agent"; - const isOpenIntentWaitingForWorkspace = Boolean( - isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), - ); + const openValue = getParamValue(params.open); + // A restored workspace with no open chat has nothing else demanding its host's directory on a + // phone, where the sidebar only demands while its panel is active. Without demand the deck + // waits on workspace hydration that never starts. + useDirectoryDemand(serverId); useEffect(() => { if (!serverId || !workspaceId) { return; @@ -126,74 +122,31 @@ function HostWorkspaceRouteContent() { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit(); }, [serverId, workspaceId]); - useEffect(() => { - if (!openValue) { - return; - } - if (!rootNavigationState?.key) { - return; - } - if (!hasHydratedWorkspaceLayoutStore) { - return; - } - if (isOpenIntentWaitingForWorkspace) { - return; - } - - const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; - if (consumedIntentRef.current === consumptionKey) { - clearConsumedOpenIntent({ - navigation: navigation as unknown as { - setParams: (params: { open?: string | undefined }) => void; - }, - }); - setIntentConsumed(true); - return; - } - consumedIntentRef.current = consumptionKey; - + useLayoutEffect(() => { + if (!openValue || !serverId || !workspaceId) return; + const openIntent = parseWorkspaceOpenIntent(openValue); if (openIntent) { - prepareWorkspaceTab({ - serverId, - workspaceId, - target: getOpenIntentTarget(openIntent), - pin: openIntent.kind === "agent", - }); + prepareWorkspaceTab( + { + serverId, + workspaceId, + target: getOpenIntentTarget(openIntent), + pin: openIntent.kind === "agent", + }, + { openTab: useWorkspaceLayoutStore.getState().deferOpenTab }, + ); } - - // Expo Router's replace ignores query-param-only changes (findDivergentState - // skips search params). Strip ?open from the browser URL directly so the - // address bar reflects the clean workspace route. clearConsumedOpenIntent({ navigation: navigation as unknown as { setParams: (params: { open?: string | undefined }) => void; }, }); + }, [navigation, openValue, serverId, workspaceId]); - setIntentConsumed(true); - }, [ - hasHydratedWorkspaceLayoutStore, - isOpenIntentWaitingForWorkspace, - navigation, - openIntent, - openValue, - rootNavigationState?.key, - serverId, - workspaceId, - ]); - - if ( - openValue && - !isOpenIntentWaitingForWorkspace && - (!intentConsumed || !hasHydratedWorkspaceLayoutStore) - ) { - return null; - } - - return ; + return ; } -function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { +function WorkspaceDeck() { const activeSelection = useActiveWorkspaceSelection(); const [retainedSelections, setRetainedSelections] = useState(() => activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [], @@ -264,7 +217,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { key={getWorkspaceSelectionKey(selection)} selection={selection} active={active} - recoveryRequested={recoveryRequested} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -277,12 +229,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { function WorkspaceDeckEntry({ selection, active, - recoveryRequested, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; active: boolean; - recoveryRequested: boolean; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId); @@ -312,7 +262,6 @@ function WorkspaceDeckEntry({ serverId={selection.serverId} workspaceId={selection.workspaceId} isRouteFocused={active} - recoveryRequested={active && recoveryRequested} /> ); diff --git a/packages/app/src/app/settings/[section].tsx b/packages/app/src/app/settings/[section].tsx index 8b036e2aac..583428864c 100644 --- a/packages/app/src/app/settings/[section].tsx +++ b/packages/app/src/app/settings/[section].tsx @@ -35,10 +35,9 @@ function SettingsDaemonRedirect() { } export default function SettingsSectionRoute() { - const params = useLocalSearchParams<{ section?: string; addHost?: string }>(); + const params = useLocalSearchParams<{ section?: string }>(); const rawSection = typeof params.section === "string" ? params.section : ""; const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general"; - const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null; const view = useMemo(() => ({ kind: "section" as const, section }), [section]); // COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08. @@ -50,5 +49,5 @@ export default function SettingsSectionRoute() { ); } - return ; + return ; } diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index cebb4ca76d..703473e6be 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -53,9 +53,10 @@ export const ACP_PROVIDER_ICON_SVGS = { junie: '\n \n \n \n\n', kilo: '\n \n\n', + kiro: ' ', kimi: '\n \n\n', "minimax-code": - '\n \n\n', + '\n \n\n', "minion-code": '\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n', "mistral-vibe": @@ -74,7 +75,11 @@ export const ACP_PROVIDER_ICON_SVGS = { stakpak: '\n \n\n', traecli: - '\n \n\n', + '\n \n\n', vtcode: '\n \n VT\n\n', + devin: + '\n \n\n', + hermes: + '\n \n\n', } as const; diff --git a/packages/app/src/assets/acp-provider-icons/devin.svg b/packages/app/src/assets/acp-provider-icons/devin.svg new file mode 100644 index 0000000000..e0f58c6513 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assets/acp-provider-icons/grok.svg b/packages/app/src/assets/acp-provider-icons/grok.svg new file mode 100644 index 0000000000..35db840319 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/assets/acp-provider-icons/hermes.svg b/packages/app/src/assets/acp-provider-icons/hermes.svg new file mode 100644 index 0000000000..c6fdd22500 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/hermes.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assets/acp-provider-icons/kiro.svg b/packages/app/src/assets/acp-provider-icons/kiro.svg new file mode 100644 index 0000000000..31715c335a --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/kiro.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/assets/acp-provider-icons/minimax-code.svg b/packages/app/src/assets/acp-provider-icons/minimax-code.svg new file mode 100644 index 0000000000..61fcb8afab --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/minimax-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assets/acp-provider-icons/traecli.svg b/packages/app/src/assets/acp-provider-icons/traecli.svg new file mode 100644 index 0000000000..6d5388fcaa --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/traecli.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 9b66e99c18..6d80abb1a6 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -6,6 +6,7 @@ export { export { AssistantFileLinkResolverProvider, useAssistantFileLinkEnvironment, + useAssistantFileLinkEnvironmentBridge, type AssistantFileLinkResolverProviderProps, } from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index c195a277cf..8e02310fea 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -23,6 +23,10 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0 const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i; export interface AssistantHrefParseOptions { workspaceRoot?: string; + /** Authored document links resolve from this directory, independently of the workspace. */ + relativePathBase?: string; + /** Authored links identify a file; only inferred text needs filename heuristics. */ + explicitLink?: boolean; /** * True only when `value` is a markdown href — the renderer percent-encodes * non-ASCII and space characters, so the path must be decoded. Raw path @@ -30,8 +34,6 @@ export interface AssistantHrefParseOptions { * send a real file named `my%20file.md` to `my file.md`. */ decodeHref?: boolean; - /** Explicit Markdown links can name nested folders without a filename extension. */ - allowExtensionlessNestedPaths?: boolean; } export type AssistantFileLinkClassification = @@ -236,7 +238,7 @@ export function classifyAssistantFileLink( return null; } - if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) { + if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) { return { kind: "ambiguousFileCandidate", target, @@ -280,7 +282,8 @@ export function parseAssistantFileLink( const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, decodeHref: options.decodeHref, - allowExtensionlessNestedPaths: options.allowExtensionlessNestedPaths, + explicitLink: options.explicitLink, + relativePathBase: options.relativePathBase, }); if (relativeTarget) { return relativeTarget; @@ -395,6 +398,15 @@ function parseWorkspaceRelativeFileLink( }; } + const documentBase = options.explicitLink && normalizePathInput(options.relativePathBase); + if (documentBase) { + return { + raw: value, + path: resolveDocumentRelativePath(parsed.path, documentBase), + ...parsed.lines, + }; + } + const workspaceRoot = normalizePathInput(options.workspaceRoot); if (!workspaceRoot) { return null; @@ -414,7 +426,7 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, - options: Pick = {}, + options: Pick = {}, ): { path: string; lines: Pick; @@ -440,7 +452,7 @@ function parseLocalPathParts( if (options.decodeHref) { pathPart = safeDecodeURIComponent(pathPart); } - if (!isPlausibleAssistantLocalPath(pathPart, options)) { + if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) { return null; } return { @@ -461,7 +473,7 @@ function parseLocalPathParts( decodedBeforeHash = safeDecodeURIComponent(beforeHash); } const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; - if (!isPlausibleAssistantLocalPath(plausiblePath, options)) { + if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) { return null; } @@ -586,10 +598,7 @@ function getHeuristicLocalPath(value: string): string | null { return beforeHash; } -function isPlausibleAssistantLocalPath( - pathValue: string, - options: Pick = {}, -): boolean { +function isPlausibleAssistantLocalPath(pathValue: string): boolean { const normalized = normalizePathToken(pathValue); if (!normalized) { return false; @@ -613,10 +622,7 @@ function isPlausibleAssistantLocalPath( if (segments.length > 1) { const lastSegment = segments[segments.length - 1]; - return ( - !isDomainLikePathSegment(firstSegment) && - (options.allowExtensionlessNestedPaths === true || isPlausibleAssistantFileName(lastSegment)) - ); + return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment); } return isPlausibleAssistantFileName(firstSegment); @@ -644,6 +650,24 @@ function isDomainLikePathSegment(segment: string): boolean { return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment); } +function resolveDocumentRelativePath(relativePath: string, baseDirectory: string): string { + const prefix = baseDirectory.startsWith("/") ? "/" : ""; + const segments = baseDirectory.split("/").filter(Boolean); + for (const segment of relativePath.split("/")) { + const last = segments.at(-1) ?? ""; + if (!segment || segment === ".") continue; + if (segment !== "..") { + segments.push(segment); + } else if (last && last !== "~" && last !== ".." && !/^[A-Za-z]:$/.test(last)) { + segments.pop(); + } else if (!prefix && !/^[A-Za-z]:$/.test(last)) { + segments.push(".."); + } + } + const resolved = prefix + segments.join("/"); + return /^[A-Za-z]:$/.test(resolved) ? `${resolved}/` : resolved; +} + function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null { const normalizedPath = normalizePathToken(pathValue); if (!normalizedPath || isAbsolutePath(normalizedPath)) { diff --git a/packages/app/src/assistant-file-links/path-open.test.ts b/packages/app/src/assistant-file-links/path-open.test.ts index a046a598f6..f54ee88765 100644 --- a/packages/app/src/assistant-file-links/path-open.test.ts +++ b/packages/app/src/assistant-file-links/path-open.test.ts @@ -64,13 +64,19 @@ it("ignores an older probe after a newer file link opens", async () => { const opened: string[] = []; const opener = createInlinePathOpener({ workspaceRoot: "/repo", - client: { listDirectory: () => pendingProbe }, + client: { + listDirectory: () => pendingProbe, + getDirectorySuggestions: async () => ({ entries: [], error: null }), + }, openFile: (location) => { opened.push(location.path); }, openDirectory: (directory) => { opened.push(directory); }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, }); const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred"); await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred"); @@ -88,13 +94,19 @@ it("ignores a pending probe when its view is left", async () => { const opened: string[] = []; const opener = createInlinePathOpener({ workspaceRoot: "/repo", - client: { listDirectory: () => pendingProbe }, + client: { + listDirectory: () => pendingProbe, + getDirectorySuggestions: async () => ({ entries: [], error: null }), + }, openFile: (location) => { opened.push(location.path); }, openDirectory: (directory) => { opened.push(directory); }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, }); const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred"); opener.cancel(); @@ -102,3 +114,67 @@ it("ignores a pending probe when its view is left", async () => { await pending; expect(opened).toEqual([]); }); + +it("sends folders outside the workspace away from the workspace Files panel", async () => { + const { createInlinePathOpener } = await import("./path-open"); + const workspaceFolders: string[] = []; + const outsideFolders: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/Users/me/repo", + client: null, + openFile: () => {}, + openDirectory: (directory) => { + workspaceFolders.push(directory); + }, + openOutsideDirectory: (directory) => { + outsideFolders.push(directory); + }, + }); + for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) { + await opener.open({ raw, path: raw }, "preferred"); + } + expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); + expect(workspaceFolders).toEqual(["docs"]); +}); + +it("keeps a later selection when an earlier nested-file lookup finishes last", async () => { + const { createInlinePathOpener } = await import("./path-open"); + const lookup = Promise.withResolvers<{ + entries: Array<{ path: string; kind: "file" }>; + error: null; + }>(); + const queries: string[] = []; + const opened: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/repo", + resolveRelativeFiles: true, + client: { + listDirectory: async () => { + throw new Error("Not a directory"); + }, + getDirectorySuggestions: async ({ query }: { query: string }) => { + queries.push(query); + return lookup.promise; + }, + }, + openFile: (location) => { + opened.push(location.path); + }, + openDirectory: (directory) => { + opened.push(directory); + }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, + }); + const earlier = opener.open( + { raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 }, + "side", + ); + await Promise.resolve(); + expect(queries).toEqual(["schema/x.md"]); + await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side"); + lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null }); + await earlier; + expect(opened).toEqual(["guide.md"]); +}); diff --git a/packages/app/src/assistant-file-links/path-open.ts b/packages/app/src/assistant-file-links/path-open.ts index b3d2178165..7ff50c9a1a 100644 --- a/packages/app/src/assistant-file-links/path-open.ts +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -5,6 +5,7 @@ import { type OpenFileDisposition, } from "@/workspace/file-open"; import { isAbsolutePath } from "@/utils/path"; +import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver"; interface PathOpenInput { file?: string; @@ -14,12 +15,23 @@ interface PathOpenInput { client: { listDirectory(root: string, path: string): Promise } | null; } -type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string }; +type PathOpenTarget = + | { kind: "file" } + | { kind: "directory"; directoryPath: string } + | { kind: "outsideDirectory"; directoryPath: string }; + +// Workspace paths arrive relative to the root; anything still absolute or +// home-relative lives outside it, where the Files panel cannot list it. +function isOutsideWorkspacePath(pathValue: string): boolean { + return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/"); +} /** Resolve workspace folders from filesystem facts, including names that contain dots. */ export async function resolveInlinePathOpen(input: PathOpenInput): Promise { if (!input.file) { - return { kind: "directory", directoryPath: input.directory }; + return isOutsideWorkspacePath(input.directory) + ? { kind: "outsideDirectory", directoryPath: input.directory } + : { kind: "directory", directoryPath: input.directory }; } if ( input.lineStart !== undefined || @@ -39,9 +51,14 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise; + getDirectorySuggestions: GetDirectorySuggestions; + } | null; + resolveRelativeFiles?: boolean; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openDirectory: (path: string) => void; + openOutsideDirectory: (path: string) => void; }) { let generation = 0; return { @@ -58,11 +75,24 @@ export function createInlinePathOpener(input: { if (ownGeneration !== generation) return; if (destination.kind === "directory") { input.openDirectory(destination.directoryPath); + } else if (destination.kind === "outsideDirectory") { + input.openOutsideDirectory(destination.directoryPath); } else if (normalized.file) { + const resolvedTarget = + input.resolveRelativeFiles && input.client + ? await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: input.workspaceRoot, + getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client), + }) + : target; + if (ownGeneration !== generation) return; + const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot); + if (!resolved?.file) return; const location = normalizeWorkspaceFileLocation({ - path: normalized.file, - lineStart: target.lineStart, - lineEnd: target.lineEnd, + path: resolved.file, + lineStart: resolvedTarget.lineStart, + lineEnd: resolvedTarget.lineEnd, }); if (location) input.openFile(location, disposition); } diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx index 967e1c32e3..935683bb0e 100644 --- a/packages/app/src/assistant-file-links/provider.tsx +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -22,6 +22,7 @@ export interface AssistantFileLinkResolverConfig { client?: AssistantFileLinkDaemonClient | null; serverId?: string; workspaceRoot?: string; + relativePathBase?: string; onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; toast?: ToastApi | null; } @@ -38,10 +39,23 @@ export interface AssistantFileLinkResolverContextValue { const AssistantFileLinkResolverContext = createContext(null); +export function useAssistantFileLinkEnvironmentBridge() { + const value = useContext(AssistantFileLinkResolverContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [value], + ); +} + export function AssistantFileLinkResolverProvider({ client, serverId, workspaceRoot, + relativePathBase, onOpenWorkspaceFile, toast, children, @@ -50,10 +64,18 @@ export function AssistantFileLinkResolverProvider({ client, serverId, workspaceRoot, + relativePathBase, onOpenWorkspaceFile, toast, }); - configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast }; + configRef.current = { + client, + serverId, + workspaceRoot, + relativePathBase, + onOpenWorkspaceFile, + toast, + }; const getDirectorySuggestions = useCallback(async (input) => { const activeClient = configRef.current.client; diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 854c358627..dc38c24c68 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -7,6 +7,7 @@ import { classifyForResolution, fetchDaemonResolution, getAssistantFileLinkToken, + resolveMissingNestedRelativeFile, UnresolvedFileLinkError, type AssistantFileLinkContext, type DirectorySuggestionEntry, @@ -18,6 +19,50 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it.each([ + ["guide/details.md", "/repo/docs/guide/details.md"], + ["../README.md", "/repo/README.md"], + ["guide/details%20page.md", "/repo/docs/guide/details page.md"], +])("resolves authored href %s from its document directory", (href, expected) => { + expect( + classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase: "/repo/docs" }), + ).toMatchObject({ kind: "resolved", value: { kind: "file", target: { path: expected } } }); +}); + +it.each([ + ["C:/repo/docs", "../README.md", "C:/repo/README.md"], + ["C:/repo/docs", "../../", "C:/"], + ["~/.paseo/docs", "../guide.md", "~/.paseo/guide.md"], + ["/repo/docs", "/other/file.md", "/other/file.md"], + ["/repo/docs", "file:///other/file.md", "/other/file.md"], + ["/repo/docs", "guide/details.md#L3", "/repo/docs/guide/details.md"], +])( + "resolves %s with href %s without changing absolute destinations", + (relativePathBase, href, expected) => { + expect( + classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase }), + ).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: expected } }, + }); + }, +); + +it("keeps inferred paths and chat links rooted at the workspace", () => { + const context = { workspaceRoot: "/repo", relativePathBase: "/repo/docs" }; + expect(classifyForResolution({ href: "src/file.ts", markup: "linkify" }, context)).toMatchObject({ + value: { kind: "file", target: { path: "/repo/src/file.ts" } }, + }); + expect( + classifyForResolution({ href: "guide/details.md" }, { workspaceRoot: "/repo" }), + ).toMatchObject({ + value: { kind: "file", target: { path: "/repo/guide/details.md" } }, + }); + expect(classifyForResolution({ href: "https://example.com/docs" }, context)).toMatchObject({ + value: { kind: "external", url: "https://example.com/docs" }, + }); +}); + it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])( "resolves an explicit extensionless folder href: %s", (href) => { @@ -50,7 +95,7 @@ it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])( }, ); -it.each(["example.com/guides", "docs/guides?view=all"])( +it.each(["docs/guides?view=all"])( "does not turn URL-like hrefs into workspace paths: %s", (href) => { expect(classifyForResolution({ href }, CONTEXT)).toEqual({ @@ -60,6 +105,21 @@ it.each(["example.com/guides", "docs/guides?view=all"])( }, ); +it.each([ + ".dev/tablet-review/tablet-agent-draft.png", + "images/capture.jpg", + "downloads/archive.custom", + "assets/LICENSE", + "example.com/guides", + "capture.png", + "missing.png", +])("opens explicit file link %s without filename guessing", (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } }, + }); +}); + it("opens the existing non-ASCII file named by a rendered markdown link", async () => { const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-")); try { @@ -365,6 +425,174 @@ describe("fetchDaemonResolution", () => { }); }); +describe("resolveMissingNestedRelativeFile", () => { + const target = { + raw: "schema/x.md:12-14", + path: "/Users/test/project/schema/x.md", + lineStart: 12, + lineEnd: 14, + }; + + it("opens a nested-repo suffix when the workspace-root path is missing", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(searches).toEqual([ + { + query: "schema/x.md", + cwd: "/Users/test/project", + matchMode: "suffix", + limit: 1, + }, + ]); + expect(result).toEqual({ + ...target, + path: "/Users/test/project/repo-a/schema/x.md", + }); + }); + + it("keeps the exact workspace path when suggestions return that relative path", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({ + "schema/x.md": [{ path: "schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(result).toEqual(target); + }); + + it("keeps the original target when suggestions are empty", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({}); + + await expect( + resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).resolves.toEqual(target); + }); + + it("keeps the original target when the daemon throws", async () => { + await expect( + resolveMissingNestedRelativeFile({ + target, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions: unavailableSuggestions, + }), + ).resolves.toEqual(target); + }); + + it.each([ + ["a bare filename", { ...target, path: "/Users/test/project/file.ts" }], + ["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }], + ["a home-relative path", { ...target, path: "~/schema/x.md" }], + ])("does not search %s", async (_label, candidate) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target: candidate, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }); + + expect(searches).toEqual([]); + expect(result).toEqual(candidate); + }); + + it("does not search when the workspace root is blank", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({}); + + const result = await resolveMissingNestedRelativeFile({ + target, + workspaceRoot: " ", + getDirectorySuggestions, + }); + + expect(searches).toEqual([]); + expect(result).toEqual(target); + }); + + it.each([ + ["/Users/test/project/schema/x.md", "/Users/test/project"], + ["file:///Users/test/project/schema/x.md", "/Users/test/project"], + ["C:/repo/schema/x.md", "C:/repo"], + ["\\\\server\\share\\schema\\x.md", "//server/share"], + ])("keeps absolute destination %s", async (raw, workspaceRoot) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` }; + await expect( + resolveMissingNestedRelativeFile({ + target: absoluteTarget, + workspaceRoot, + getDirectorySuggestions, + }), + ).resolves.toEqual(absoluteTarget); + expect(searches).toEqual([]); + }); + + it.each(["docs/guides", "./docs/guides", "docs/guides/"])( + "keeps folder href %s direct", + async (raw) => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }], + }); + const folderTarget = { raw, path: "/Users/test/project/docs/guides" }; + await expect( + resolveMissingNestedRelativeFile({ + target: folderTarget, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).resolves.toEqual(folderTarget); + expect(searches).toEqual([]); + }, + ); + + it("normalizes a Windows workspace root before the suffix lookup", async () => { + const windowsTarget = { + raw: "schema/x.md", + path: "C:\\repo\\schema\\x.md", + lineStart: undefined, + lineEnd: undefined, + }; + const { getDirectorySuggestions, searches } = suggestionsFromMap({ + "schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }], + }); + + const result = await resolveMissingNestedRelativeFile({ + target: windowsTarget, + workspaceRoot: "C:\\repo\\", + getDirectorySuggestions, + }); + + expect(searches).toEqual([ + { + query: "schema/x.md", + cwd: "C:\\repo\\", + matchMode: "suffix", + limit: 1, + }, + ]); + expect(result.path).toBe("C:/repo/repo-a/schema/x.md"); + }); +}); + describe("getAssistantFileLinkToken", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { expect( diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 59057ba0ca..f77800b75b 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -5,6 +5,7 @@ import { type InlinePathTarget, } from "./parse"; import { i18n } from "@/i18n/i18next"; +import { isAbsolutePath } from "@/utils/path"; export interface AssistantFileLinkSource { href: string; @@ -17,6 +18,7 @@ export interface AssistantFileLinkSource { export interface AssistantFileLinkContext { workspaceRoot?: string; + relativePathBase?: string; } export interface DirectorySuggestionEntry { @@ -104,6 +106,62 @@ export async function fetchDaemonResolution({ }; } +/** Only file-looking paths nested inside the workspace need suffix search. */ +function nestedRelativeLookupQuery( + targetPath: string, + workspaceRoot: string | undefined, +): string | null { + const root = workspaceRoot?.trim(); + if (!root) { + return null; + } + const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = targetPath.replace(/\\/g, "/"); + const prefix = `${normalizedRoot}/`; + if (!normalizedPath.startsWith(prefix)) { + return null; + } + const relative = normalizedPath.slice(prefix.length); + if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) { + return null; + } + return relative; +} + +/** When a markdown link points at workspaceRoot/schema/x.md and that file is + * missing, ask the same suffix lookup bare names already use. An exact hit + * stays on that path. A suffix hit such as repo-a/schema/x.md replaces it. + * Lookup failure keeps the original target. + */ +export async function resolveMissingNestedRelativeFile(input: { + target: InlinePathTarget; + workspaceRoot?: string; + getDirectorySuggestions: GetDirectorySuggestions; +}): Promise { + if ( + isAbsoluteInlineCodeToken(input.target.raw) || + input.target.raw.startsWith("~/") || + input.target.raw.endsWith("/") + ) { + return input.target; + } + const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot); + if (!query) { + return input.target; + } + try { + return await fetchDaemonResolution({ + ambiguousQuery: query, + token: input.target.raw, + target: input.target, + workspaceRoot: input.workspaceRoot, + getDirectorySuggestions: input.getDirectorySuggestions, + }); + } catch { + return input.target; + } +} + export function classifyForResolution( source: AssistantFileLinkSource, context: AssistantFileLinkContext, @@ -117,7 +175,8 @@ export function classifyForResolution( const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, decodeHref: tokenInfo.fromHref, - allowExtensionlessNestedPaths: tokenInfo.fromHref && !isLinkifiedSource(source), + explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source), + relativePathBase: context.relativePathBase, }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; @@ -213,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: { } function isAbsoluteInlineCodeToken(token: string): boolean { - return ( - token.startsWith("/") || - token.toLowerCase().startsWith("file://") || - /^[A-Za-z]:[\\/]/.test(token) - ); + return isAbsolutePath(token) || token.toLowerCase().startsWith("file://"); } function isLinkifiedSource(source: AssistantFileLinkSource): boolean { diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx index 386cb429f1..18d2727e82 100644 --- a/packages/app/src/assistant-file-links/use-file-link.test.tsx +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -2,9 +2,9 @@ * @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import React, { useCallback, useMemo, useState, type ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToastApi } from "@/components/toast-host"; import type { InlinePathTarget } from "./parse"; import { AssistantFileLinkResolverProvider } from "./provider"; @@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t } describe("useFileLink", () => { + // Lookups resolve after a test's last assertion; an unmounted hook cannot re-render + // once jsdom is torn down. + afterEach(() => { + cleanup(); + }); + it("returns the same object across no-op parent rerenders", () => { const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([])); const queryClient = createQueryClient(); diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 4f117e5dc1..344727c571 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -15,6 +15,7 @@ import { fetchDaemonResolution, type AssistantFileLinkResolution, type AssistantFileLinkSource, + type AssistantFileLinkContext, } from "./resolver"; export interface UseFileLinkResult { @@ -54,13 +55,15 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult const stableSource = useStableSource(source); const activeConfig = context.configRef.current; const workspaceRoot = activeConfig.workspaceRoot; + const relativePathBase = activeConfig.relativePathBase; const serverId = activeConfig.serverId; const resolution = useMemo( () => classifyForResolution(stableSource, { workspaceRoot, + relativePathBase, }), - [stableSource, workspaceRoot], + [stableSource, workspaceRoot, relativePathBase], ); const queryKey = useMemo( () => @@ -154,12 +157,12 @@ export function useAssistantFileLinkActions(): AssistantFileLinkActions { ); const canOpen = useCallback( (source: AssistantFileLinkSource) => - canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot), + canOpenAssistantFileLink(source, context.configRef.current), [context.configRef], ); const canResolveFile = useCallback( (source: AssistantFileLinkSource) => - canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot), + canResolveAssistantFileLinkToFile(source, context.configRef.current), [context.configRef], ); @@ -176,6 +179,7 @@ function openAssistantFileLink(input: { const capturedConfig = input.context.configRef.current; const capturedResolution = classifyForResolution(input.source, { workspaceRoot: capturedConfig.workspaceRoot, + relativePathBase: capturedConfig.relativePathBase, }); if (capturedResolution.kind === "resolved") { @@ -233,17 +237,17 @@ function openAssistantFileLink(input: { function canOpenAssistantFileLink( source: AssistantFileLinkSource, - workspaceRoot: string | undefined, + context: AssistantFileLinkContext, ): boolean { - const resolution = classifyForResolution(source, { workspaceRoot }); + const resolution = classifyForResolution(source, context); return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored"; } function canResolveAssistantFileLinkToFile( source: AssistantFileLinkSource, - workspaceRoot: string | undefined, + context: AssistantFileLinkContext, ): boolean { - const resolution = classifyForResolution(source, { workspaceRoot }); + const resolution = classifyForResolution(source, context); return resolution.kind === "needsLookup" || resolution.value.kind === "file"; } diff --git a/packages/app/src/attachments/upload-limit.ts b/packages/app/src/attachments/upload-limit.ts new file mode 100644 index 0000000000..8a81043f4b --- /dev/null +++ b/packages/app/src/attachments/upload-limit.ts @@ -0,0 +1,2 @@ +/** Largest file the app sends through `client.uploadFile`. */ +export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 607f24c214..309564bcea 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,6 +1,8 @@ +import { Modal } from "@/components/ui/modal"; +import equal from "fast-deep-equal"; +import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { FlatList, - Modal, Pressable, Text, View, @@ -10,13 +12,29 @@ import { type NativeSyntheticEvent, type PressableStateCallbackType, } from "react-native"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; -import { AgentStatusDot } from "@/components/agent-status-dot"; +import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; +import { StatusIcon } from "@/components/status-icon"; +import { useWorkspaceStatus } from "@/workspace-status"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import type { Theme } from "@/styles/theme"; +import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text"; +import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; import { MaterialFileIcon } from "@/components/material-file-icon"; +import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { EditingTextInput as TextInput, @@ -29,8 +47,11 @@ import { } from "@/components/ui/isolated-bottom-sheet-modal"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; -import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useProjects } from "@/hooks/use-projects"; +import { useTimeAgo } from "@/hooks/use-time-ago"; +import { createProjectIconTarget } from "@/projects/icon-target"; +import { useProjectIcons } from "@/projects/icons"; import { OverlayLayerProvider, useGlobalWebOverlayLayer, @@ -49,17 +70,21 @@ import { } from "@/utils/command-center-focus-restore"; import { focusWithRetries } from "@/utils/web-focus"; import { navigateToAgent } from "@/utils/navigate-to-agent"; -import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { + COMMAND_CENTER_LINE_HEIGHT, + COMMAND_CENTER_LIST_METRICS, + COMMAND_CENTER_SECTION_GAP, + type CommandCenterListMetrics, +} from "./list-metrics"; import { useCommandCenterContributions } from "./provider"; import { filterAndRankWorkspaces } from "./workspace-search"; import { buildContributionSections, filterAndRankBuiltInResults, joinSubtitleParts, - moveActiveResultId, PINNED_SECTION_BAND, - preserveActiveResultId, projectCommandCenterRows, type CommandCenterAgentResult, type CommandCenterFileResult, @@ -69,6 +94,7 @@ import { type CommandCenterSearchFields, type CommandCenterWorkspaceResult, } from "./results"; +import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction"; import { useWorkspaceFileSearch } from "./workspace-file-search"; const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({ @@ -89,105 +115,128 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; -function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { - const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; - const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0; - if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput; - const leftAttention = left.requiresAttention ? 1 : 0; - const rightAttention = right.requiresAttention ? 1 : 0; - if (leftAttention !== rightAttention) return rightAttention - leftAttention; - const leftRunning = left.status === "running" ? 1 : 0; - const rightRunning = right.status === "running" ? 1 : 0; - if (leftRunning !== rightRunning) return rightRunning - leftRunning; - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); -} - -function compareWorkspacesByTitle( - left: CommandCenterWorkspaceResult, - right: CommandCenterWorkspaceResult, -): number { - const titleDelta = left.title.localeCompare(right.title, undefined, { - numeric: true, - sensitivity: "base", - }); - return titleDelta || left.subtitle.localeCompare(right.subtitle); -} - /** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields { return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] }; } +function createWorkspaceResultAction(serverId: string, workspaceId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToWorkspace({ serverId, workspaceId }); + }; +} + +function createAgentResultAction(serverId: string, agentId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId, agentId }); + }; +} + /** - * Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this - * work depends on what was typed, and rebuilding it per keystroke means re-running an Intl - * collation sort over every workspace. - * - * The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps - * now refresh when agents or projects change rather than on every keystroke. + * Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what + * was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke. */ function useBuiltInRows(open: boolean): { workspaces: CommandCenterWorkspaceResult[]; agents: CommandCenterAgentResult[]; } { const { t } = useTranslation(); + const previousRows = useRef( + new Map(), + ); const { agents } = useAggregatedAgents({ demand: open }); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; + const iconTargets = useMemo( + () => + open + ? projects.flatMap((project) => + project.hosts.flatMap((host) => { + const target = createProjectIconTarget({ + projectViewKey: project.viewKey, + placement: { ...host, iconWorkingDir: host.repoRoot }, + }); + return target ? [target] : []; + }), + ) + : [], + [open, projects], + ); + const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets }); return useMemo(() => { - if (!open) return { workspaces: [], agents: [] }; + if (!open) { + previousRows.current.clear(); + return { workspaces: [], agents: [] }; + } + // Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged + // row data so a directory update does not invalidate every mounted ResultRow. + const nextRows = new Map(); + const preserve = ( + row: T, + ): T => { + const previous = previousRows.current.get(row.id); + if (previous) row.run = previous.run; + const stable = previous && equal(previous, row) ? (previous as T) : row; + nextRows.set(row.id, stable); + return stable; + }; const allWorkspaces: CommandCenterWorkspaceResult[] = []; for (const project of projects) { + const workspaceProject = { + viewKey: project.viewKey, + name: project.projectName, + iconDataUri: iconDataByProjectViewKey.get(project.viewKey) ?? null, + }; for (const host of project.hosts) { for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; - allWorkspaces.push({ - kind: "workspace", - id: `workspace:${host.serverId}:${workspace.id}`, - title: workspace.title ?? workspace.name, - subtitle: joinSubtitleParts([ - showHost ? host.serverName : null, - project.projectName, - workspace.currentBranch, - ]), - changeRequestNumber: workspace.changeRequestNumber, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); - }, - }); + allWorkspaces.push( + preserve({ + kind: "workspace", + id: `workspace:${host.serverId}:${workspace.id}`, + title: workspace.title ?? workspace.name, + subtitle: joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]), + serverId: host.serverId, + workspaceId: workspace.id, + project: workspaceProject, + changeRequestNumber: workspace.changeRequestNumber, + activityAt: workspace.statusEnteredAt, + run: createWorkspaceResultAction(host.serverId, workspace.id), + }), + ); } } } - allWorkspaces.sort(compareWorkspacesByTitle); const workspaceTitleByKey = new Map( allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), ); - const agentRows = agents - .map((agent) => { - const workspaceTitle = agent.workspaceId - ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) - : undefined; - return { - kind: "agent", - id: `agent:${agent.serverId}:${agent.id}`, - agent, - title: agent.title || t("shell.commandCenter.newAgent"), - subtitle: joinSubtitleParts([ - showHost ? agent.serverLabel : null, - workspaceTitle ?? shortenPath(agent.cwd), - formatTimeAgo(agent.lastActivityAt), - ]), - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; - }) - .sort((left, right) => sortAgents(left.agent, right.agent)); + const agentRows = agents.map((agent) => { + const workspaceTitle = agent.workspaceId + ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) + : undefined; + return preserve({ + kind: "agent", + id: `agent:${agent.serverId}:${agent.id}`, + agent, + title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"), + subtitle: joinSubtitleParts([ + showHost ? agent.serverLabel : null, + workspaceTitle ?? shortenPath(agent.cwd), + ]), + activityAt: agent.lastActivityAt, + run: createAgentResultAction(agent.serverId, agent.id), + }); + }); + previousRows.current = nextRows; return { workspaces: allWorkspaces, agents: agentRows }; - }, [agents, open, projects, showHost, t]); + }, [agents, iconDataByProjectViewKey, open, projects, showHost, t]); } function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { @@ -202,16 +251,14 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe band: PINNED_SECTION_BAND, rank: 2, title: t("shell.commandCenter.workspaces"), - results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle), + results: filterAndRankWorkspaces(rows.workspaces, query), }, { id: "agents", band: PINNED_SECTION_BAND, rank: 3, title: t("shell.commandCenter.agents"), - results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) => - sortAgents(left.agent, right.agent), - ), + results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields), }, ]; }, [open, query, rows, t]); @@ -225,8 +272,6 @@ interface CommandCenterState { setQuery(query: string): void; activeId: string | null; rows: readonly CommandCenterListRow[]; - results: readonly CommandCenterResult[]; - rowIndexByResultId: ReadonlyMap; offsets: readonly number[]; inputRef: React.RefObject; fileSearchLoading: boolean; @@ -236,7 +281,10 @@ interface CommandCenterState { key(key: string): boolean; } -function useCommandCenterState(): CommandCenterState { +function useCommandCenterState( + listMetrics: CommandCenterListMetrics, + interaction: CommandCenterInteraction, +): CommandCenterState { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); @@ -246,8 +294,7 @@ function useCommandCenterState(): CommandCenterState { const snapshot = useCommandCenterContributions(); const inputRef = useRef(null); const previousOpenRef = useRef(open); - const [query, setQueryState] = useState(""); - const [activeId, setActiveId] = useState(null); + const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot); const builtInSections = useBuiltInSections(open, query); const { entries: fileSearchEntries, @@ -290,18 +337,13 @@ function useCommandCenterState(): CommandCenterState { scope === "files" ? fileSections : [...contributionSections, ...fileSections, ...builtInSections], + listMetrics, ), - [builtInSections, contributionSections, fileSections, scope], + [builtInSections, contributionSections, fileSections, listMetrics, scope], ); - const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); - - // Editing the query re-ranks everything, so an arrow-key selection made under the previous - // query is no longer meaningful. Reset it in the same update as the query rather than in an - // effect, so there is no intermediate render holding the stale selection. - const setQuery = useCallback((next: string) => { - setQueryState(next); - setActiveId(null); - }, []); + useLayoutEffect(() => { + interaction.update({ open, scope, query, projection, loading: fileSearchLoading }); + }, [interaction, open, scope, query, projection, fileSearchLoading]); const close = useCallback(() => setOpen(false), [setOpen]); const select = useCallback( @@ -324,18 +366,25 @@ function useCommandCenterState(): CommandCenterState { return true; } if (pressed === "Enter") { - const selected = results.find((result) => result.id === resolvedActiveId); + const selected = results.find((result) => result.id === activeId); if (!selected) return false; select(selected); return true; } if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false; - if (results.length === 0) return false; - const direction = pressed === "ArrowDown" ? "next" : "previous"; - setActiveId(moveActiveResultId(resolvedActiveId, results, direction)); - return true; + return interaction.move(pressed === "ArrowDown" ? "next" : "previous"); }, - [close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope], + [ + close, + open, + projection.selectableResults, + query, + activeId, + scope, + select, + setScope, + interaction, + ], ); useEffect(() => { @@ -345,8 +394,6 @@ function useCommandCenterState(): CommandCenterState { const timer = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(timer); } - setQueryState(""); - setActiveId(null); if (!wasOpen) return; const element = takeCommandCenterFocusRestoreElement(); if (!element) return; @@ -364,11 +411,9 @@ function useCommandCenterState(): CommandCenterState { scope, clearScope: () => setScope(null), query, - setQuery, - activeId: resolvedActiveId, + setQuery: interaction.setQuery, + activeId, rows: projection.rows, - results: projection.selectableResults, - rowIndexByResultId: projection.rowIndexByResultId, offsets: projection.offsets, inputRef, fileSearchLoading, @@ -381,11 +426,20 @@ function useCommandCenterState(): CommandCenterState { interface ResultRowProps { result: CommandCenterResult; + height: number; + query: string; active: boolean; onSelect(result: CommandCenterResult): void; } -const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) { +const ResultRow = memo(function ResultRow({ + result, + height, + query, + active, + onSelect, +}: ResultRowProps) { + recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]); const press = useCallback(() => onSelect(result), [onSelect, result]); const choice = result.kind === "contribution" && result.contribution.presentation.kind === "choice" @@ -402,15 +456,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo const style = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.row, - (result.kind === "agent" || - result.kind === "workspace" || - (result.kind === "contribution" && - result.contribution.presentation.kind === "action" && - Boolean(result.contribution.presentation.subtitle))) && - styles.tallRow, + { height }, (Boolean(hovered) || pressed || active) && styles.activeRow, ], - [active, result], + [active, height], ); return ( - + {(state) => ( + + )} ); }); -function ResultContent({ result }: { result: CommandCenterResult }) { +/** + * Text with the query's terms marked. Marks use the same matcher options the rows were ranked + * with, so a mark always points at something that made the row match. + */ +function QueryHighlightedText({ + query, + text, + ...props +}: { query: string } & Omit) { + const ranges = useMemo(() => findHighlightRanges(query, text), [query, text]); + return ; +} + +/** + * The subtitle line of a workspace or agent row. The timestamp is its own text so a long branch + * truncates without pushing the time out of view, and so the time is never part of what the + * query matches against. It keeps itself current while the command center stays open. + */ +function SubtitleWithTimestamp({ + query, + subtitle, + activityAt, + testID, +}: { + query: string; + subtitle: string; + activityAt: Date | null; + testID: string; +}) { + const timeAgo = useTimeAgo(activityAt); + return ( + + + {timeAgo ? ( + + {/* Non-breaking: web collapses a leading plain space in its own flex item. */} + {subtitle ? `\u00a0· ${timeAgo}` : timeAgo} + + ) : null} + + ); +} + +const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function AgentResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterAgentResult; + query: string; + backdrop: SurfaceBackdrop; +}) { + const agent = result.agent; + const descriptor = useAgentDescriptor( + { kind: "agent", agentId: agent.id }, + { serverId: agent.serverId }, + ); + const Icon = useMemo( + () => withUnistyles(descriptor.icon, providerIconMapping), + [descriptor.icon], + ); + return ( + + + + + + + + + + + + + + + + ); +} + +function WorkspaceResultIcon({ + result, + backdrop, +}: { + result: CommandCenterWorkspaceResult; + backdrop: SurfaceBackdrop; +}) { + const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId); + return ( + + + + + + ); +} + +/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */ +function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) { + const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase(); + return ( + + ); +} + +function ResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterResult; + query: string; + backdrop: SurfaceBackdrop; +}) { if (result.kind === "file") { return ( @@ -438,13 +636,16 @@ function ResultContent({ result }: { result: CommandCenterResult }) { - - {result.title} - + {result.subtitle ? ( {" "} - {result.subtitle} + ) : null} @@ -453,48 +654,30 @@ function ResultContent({ result }: { result: CommandCenterResult }) { ); } if (result.kind === "agent") { - const agent = result.agent; - return ( - - - - - - - - {result.title} - - - {result.subtitle} - - - - - ); + return ; } if (result.kind === "workspace") { const key = result.id.slice("workspace:".length); return ( + {/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */} - + - - {result.title} - - + - {result.subtitle} - + /> @@ -512,11 +695,19 @@ function ResultContent({ result }: { result: CommandCenterResult }) { ) : null} - - {presentation.title} - + {presentation.subtitle ? ( - {presentation.subtitle} + ) : null} @@ -541,14 +732,14 @@ function ResultContent({ result }: { result: CommandCenterResult }) { style={styles.breadcrumbPart} > {index > 0 ? : null} - - {part} - + />
))}
@@ -563,11 +754,9 @@ function ResultContent({ result }: { result: CommandCenterResult }) { } function SectionRow({ row }: { row: Extract }) { - let sizeStyle = styles.dividerSection; - if (row.title && row.divider) sizeStyle = styles.dividedSection; - if (row.title && !row.divider) sizeStyle = styles.titledSection; + const style = useMemo(() => [styles.section, { height: row.height }], [row.height]); return ( - + {row.divider ? : null} {row.title ? {row.title} : null} @@ -576,61 +765,40 @@ function SectionRow({ row }: { row: Extract>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef(null); - const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 }); + const [interaction] = useState(() => + createCommandCenterInteraction((offset) => { + const list = listRef.current ?? bottomSheetListRef.current; + list?.scrollToOffset({ offset, animated: false }); + }), + ); + const state = useCommandCenterState( + isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop, + interaction, + ); + const showBottomSheet = isCompact && isNative; + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible: state.open, isEnabled: showBottomSheet, onClose: state.close, }); - const revealActiveResult = useCallback(() => { - if (!state.open || !state.activeId) return; - const index = state.rowIndexByResultId.get(state.activeId); - if (index === undefined) return; - const { offset, visibleLength } = scrollMetricsRef.current; - if (visibleLength <= 0) return; - const rowTop = state.offsets[index]; - const rowBottom = rowTop + state.rows[index].height; - let nextOffset: number | null = null; - if (rowTop < offset) nextOffset = rowTop; - if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength; - if (nextOffset === null) return; - const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current; - const boundedOffset = Math.max(0, nextOffset); - scrollMetricsRef.current.offset = boundedOffset; - ref?.scrollToOffset({ offset: boundedOffset, animated: false }); - }, [ - showBottomSheet, - state.activeId, - state.offsets, - state.open, - state.rowIndexByResultId, - state.rows, - ]); - useEffect(() => { - if (!state.open) { - scrollMetricsRef.current = { offset: 0, visibleLength: 0 }; - return; - } - revealActiveResult(); - }, [revealActiveResult, state.open]); const handleListLayout = useCallback( (event: LayoutChangeEvent) => { - scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height; - revealActiveResult(); + interaction.layout(event.nativeEvent.layout.height); }, - [revealActiveResult], + [interaction], + ); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + interaction.scrolled(event.nativeEvent.contentOffset.y); + }, + [interaction], ); - const handleListScroll = useCallback((event: NativeSyntheticEvent) => { - scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y; - }, []); useEffect(() => { if (!showBottomSheet || !state.open) return; const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); @@ -644,11 +812,13 @@ export function CommandCenter() { ) : ( ), - [state.activeId, state.select], + [state.activeId, state.query, state.select], ); const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ @@ -694,6 +864,14 @@ export function CommandCenter() { windowSize: 5, onLayout: handleListLayout, onScroll: handleListScroll, + onScrollBeginDrag: interaction.beginManualScroll, + onTouchStart: interaction.beginManualScroll, + ...(isWeb + ? { + onWheel: interaction.beginManualScroll, + onMouseDown: interaction.beginManualScroll, + } + : {}), scrollEventThrottle: 16, }; const keyPress = useCallback( @@ -847,7 +1025,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingTop: theme.spacing[12], }, - backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" }, + backdrop: { ...StyleSheet.absoluteFill, backgroundColor: "rgba(0, 0, 0, 0.5)" }, panel: { width: 640, height: 560, @@ -901,20 +1079,21 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: "500", }, results: { flex: 1 }, + // Sections and rows take their height from list-metrics; these styles only place content in it. + section: { justifyContent: "flex-end" }, sectionLabel: { paddingHorizontal: theme.spacing[4], - paddingBottom: theme.spacing[2], + paddingBottom: COMMAND_CENTER_SECTION_GAP, fontSize: theme.fontSize.sm, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.sectionLabel, color: theme.colors.foregroundMuted, }, sectionDivider: { height: 1, - marginTop: theme.spacing[2], - marginBottom: theme.spacing[2], + marginVertical: COMMAND_CENTER_SECTION_GAP, backgroundColor: theme.colors.border, }, - row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] }, - tallRow: { height: 56 }, + row: { paddingHorizontal: theme.spacing[4], justifyContent: "center" }, activeRow: { backgroundColor: theme.colors.surface1 }, rowContent: { flexDirection: "row", @@ -933,14 +1112,31 @@ const styles = StyleSheet.create((theme) => ({ title: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - lineHeight: 18, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.title, flexShrink: 1, }, - fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, lineHeight: 20 }, + fileLine: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.base, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine, + }, fileName: { color: theme.colors.foreground }, filePath: { color: theme.colors.foregroundMuted }, - subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 16 }, - iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, + subtitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.subtitle, + }, + subtitleLine: { flexDirection: "row", minWidth: 0 }, + subtitleText: { flexShrink: 1, minWidth: 0 }, + iconSlot: { + width: 16, + height: COMMAND_CENTER_LINE_HEIGHT.singleLine, + alignItems: "center", + justifyContent: "center", + }, + projectIconFallbackText: { fontSize: 9 }, rowShortcut: { flexShrink: 0 }, breadcrumb: { flex: 1, @@ -958,7 +1154,7 @@ const styles = StyleSheet.create((theme) => ({ breadcrumbGroup: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, - lineHeight: 20, + lineHeight: COMMAND_CENTER_LINE_HEIGHT.singleLine, flexShrink: 0, }, emptyText: { @@ -978,7 +1174,4 @@ const styles = StyleSheet.create((theme) => ({ }, sheetBackground: { backgroundColor: theme.colors.surface0 }, sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] }, - titledSection: { height: 32, justifyContent: "flex-end" }, - dividedSection: { height: 49, justifyContent: "flex-end" }, - dividerSection: { height: 17, justifyContent: "flex-end" }, })); diff --git a/packages/app/src/command-center/interaction.test.ts b/packages/app/src/command-center/interaction.test.ts new file mode 100644 index 0000000000..5b5d8bdef9 --- /dev/null +++ b/packages/app/src/command-center/interaction.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { createCommandCenterInteraction } from "./interaction"; +import { projectCommandCenterRows, type CommandCenterFileResult } from "./results"; +import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store"; + +function results(ids = ["a", "b", "c", "d"]) { + return projectCommandCenterRows( + [ + { + id: "files", + band: 0, + rank: 0, + title: "Files", + results: ids.map((id) => ({ + id, + kind: "file", + filePath: id, + title: id, + subtitle: "", + run() {}, + })), + }, + ], + { singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 }, + ); +} + +function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) { + const offsets: number[] = []; + const model = createCommandCenterInteraction((offset) => offsets.push(offset)); + const publish = (ids = ["a", "b", "c", "d"], loading = false) => + model.update({ + open: true, + scope: options.scope ?? null, + query: model.getSnapshot().query, + projection: results(ids), + loading, + }); + publish(); + if (options.measured !== false) model.layout(80); + return { model, offsets, publish }; +} + +describe("Command Center interaction", () => { + it("keeps manual scrolling through result refresh, removal, and resizing", () => { + const { model, offsets, publish } = setup(); + model.beginManualScroll(); + model.scrolled(100); + publish(); + publish(["b", "c", "d"]); + model.layout(60); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([]); + }); + + it("reveals only clipped keyboard selections and wraps around", () => { + const { model, offsets } = setup(); + expect(model.move("next")).toBe(true); + expect(offsets).toEqual([20]); + model.scrolled(20); // The programmatic scroll callback is only a measurement. + model.move("next"); + expect(offsets).toEqual([20, 60]); + model.move("previous"); + expect(offsets).toEqual([20, 60]); + model.move("previous"); + expect(offsets).toEqual([20, 60, 20]); + model.move("previous"); + expect(model.getSnapshot().activeId).toBe("d"); + expect(offsets).toEqual([20, 60, 20, 100]); + }); + + it("keyboard navigation takes control again after manual scrolling", () => { + const { model, offsets } = setup(); + model.beginManualScroll(); + model.scrolled(100); + model.move("next"); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([60]); + }); + + it("a new query reveals even when the active ID stays the same", () => { + const { model, offsets, publish } = setup(); + model.scrolled(100); + model.setQuery("a"); + expect(offsets).toEqual([]); + publish(["a", "b"]); + expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" }); + expect(offsets).toEqual([20]); + model.scrolled(100); + publish(["a", "b"]); + expect(offsets).toEqual([20]); + }); + + it("an arrow command reveals a single result even when its ID cannot change", () => { + const { model, offsets, publish } = setup(); + publish(["a"]); + model.scrolled(30); + model.move("next"); + expect(offsets).toEqual([20]); + }); + + it("defers reveals until layout exists and consumes them only once", () => { + const { model, offsets, publish } = setup({ measured: false }); + model.move("previous"); + expect(offsets).toEqual([]); + model.layout(80); + expect(offsets).toEqual([100]); + model.scrolled(0); + publish(); + model.layout(60); + expect(offsets).toEqual([100]); + }); + + it("manual interaction cancels an unmeasured request", () => { + const { model, offsets } = setup({ measured: false }); + model.move("previous"); + model.beginManualScroll(); + model.layout(80); + expect(offsets).toEqual([]); + }); + + it("waits for scoped search results instead of revealing the previous query's rows", () => { + const { model, offsets, publish } = setup({ scope: "files" }); + model.scrolled(100); + model.setQuery("new"); + publish(["a", "b"], true); + expect(offsets).toEqual([]); + expect(model.getSnapshot().activeId).toBeNull(); + publish(["b", "a"]); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([20]); + }); + + it("manual interaction cancels a reveal awaiting search results", () => { + const { model, offsets, publish } = setup({ scope: "files" }); + model.setQuery("new"); + publish([], true); + model.beginManualScroll(); + model.scrolled(100); + publish(["new"]); + expect(model.getSnapshot().activeId).toBe("new"); + expect(offsets).toEqual([]); + }); + + it("empty searches complete without granting future passive results permission to scroll", () => { + const { model, offsets, publish } = setup(); + model.setQuery("missing"); + publish([]); + expect(model.move("next")).toBe(false); + expect(model.getSnapshot().activeId).toBeNull(); + model.scrolled(100); + publish(["late"]); + expect(offsets).toEqual([]); + }); + + it("scope changes reset selection and request a reveal", () => { + const { model, offsets } = setup(); + model.move("next"); + model.scrolled(100); + model.update({ open: true, scope: "files", query: "", projection: results(), loading: false }); + expect(model.getSnapshot().activeId).toBe("a"); + expect(offsets).toEqual([20, 20]); + }); + + it("close discards the old query, metrics, and deferred reveal", () => { + const { model, offsets, publish } = setup({ measured: false }); + model.setQuery("old"); + publish(); + model.move("previous"); + model.update({ open: false, scope: null, query: "old", projection: results(), loading: false }); + model.layout(80); + expect(model.getSnapshot()).toEqual({ query: "", activeId: null }); + expect(offsets).toEqual([]); + publish(); + expect(model.getSnapshot().activeId).toBe("a"); + expect(offsets).toEqual([]); + }); +}); diff --git a/packages/app/src/command-center/interaction.ts b/packages/app/src/command-center/interaction.ts new file mode 100644 index 0000000000..8ac0acce15 --- /dev/null +++ b/packages/app/src/command-center/interaction.ts @@ -0,0 +1,131 @@ +import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store"; +import { + moveActiveResultId, + preserveActiveResultId, + type CommandCenterListProjection, +} from "./results"; + +interface Snapshot { + query: string; + activeId: string | null; +} + +interface ResultsInput { + open: boolean; + scope: CommandCenterScope; + query: string; + projection: CommandCenterListProjection; + loading: boolean; +} + +/** Selection persists; permission to move the viewport lasts for one user command. */ +export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) { + let snapshot: Snapshot = { query: "", activeId: null }; + const listeners = new Set<() => void>(); + let open = false; + let scope: CommandCenterScope = null; + let projection: CommandCenterListProjection | null = null; + let loading = false; + let revealPending = false; + let offset = 0; + let visibleLength = 0; + + function publish(next: Snapshot) { + if (next.query === snapshot.query && next.activeId === snapshot.activeId) return; + snapshot = next; + for (const listener of listeners) listener(); + } + + function reveal() { + if (!open || !revealPending || !projection || visibleLength <= 0) return; + // Scoped file search keeps its previous entries while the next query is loading. + if (scope === "files" && loading) return; + if (!snapshot.activeId) { + if (!loading) revealPending = false; + return; + } + const index = projection.rowIndexByResultId.get(snapshot.activeId); + if (index === undefined) return; + const top = projection.offsets[index]; + const bottom = top + projection.rows[index].height; + revealPending = false; + let nextOffset = offset; + if (top < offset) nextOffset = top; + if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength; + nextOffset = Math.max(0, nextOffset); + if (nextOffset === offset) return; + offset = nextOffset; + scrollToOffset(nextOffset); + } + + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + update(input: ResultsInput) { + const resetSelection = input.open && (!open || scope !== input.scope); + open = input.open; + scope = input.scope; + if (!open) { + projection = null; + revealPending = false; + offset = 0; + visibleLength = 0; + publish({ query: "", activeId: null }); + return; + } + if (resetSelection) { + revealPending = true; + publish({ ...snapshot, activeId: null }); + } + // A query command invalidates the old projection until React supplies its new results. + if (input.query !== snapshot.query) return; + projection = input.projection; + loading = input.loading; + if (scope !== "files" || !loading) { + publish({ + ...snapshot, + activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults), + }); + } + reveal(); + }, + setQuery(query: string) { + projection = null; + revealPending = true; + publish({ query, activeId: null }); + }, + move(direction: "next" | "previous"): boolean { + if ( + !open || + !projection || + (scope === "files" && loading) || + projection.selectableResults.length === 0 + ) + return false; + publish({ + ...snapshot, + activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction), + }); + revealPending = true; + reveal(); + return true; + }, + layout(height: number) { + visibleLength = height; + reveal(); + }, + scrolled(nextOffset: number) { + offset = nextOffset; + }, + beginManualScroll() { + revealPending = false; + }, + }; +} + +export type CommandCenterInteraction = ReturnType; diff --git a/packages/app/src/command-center/list-metrics.ts b/packages/app/src/command-center/list-metrics.ts new file mode 100644 index 0000000000..603fc91e4c --- /dev/null +++ b/packages/app/src/command-center/list-metrics.ts @@ -0,0 +1,53 @@ +import { SPACING } from "@/styles/theme"; + +/** + * Every vertical size in the Command Center list, in one place. + * + * The list is virtualized, so it needs each row's height before rendering it. Rather than keep + * those numbers in sync with the styles by hand, rows render at exactly the height computed here + * and center their content. The styles read the line heights below, so this arithmetic is the + * layout. + * + * A layout chooses one thing: the inset above and below a row's text. Both layouts share the same + * shape, so nothing downstream asks which one it got. + */ +export const COMMAND_CENTER_LINE_HEIGHT = { + /** A row with no subtitle. Also the icon slot, so the icon centers on the title. */ + singleLine: 20, + title: 18, + subtitle: 16, + sectionLabel: 16, +} as const; + +/** Space around a section's label and around the rule between sections. */ +export const COMMAND_CENTER_SECTION_GAP = SPACING[2]; +const SECTION_DIVIDER_THICKNESS = 1; + +export interface CommandCenterListMetrics { + singleLineRow: number; + twoLineRow: number; + /** A section label and the gaps around it. */ + sectionTitle: number; + /** The rule between two sections and the gaps around it. */ + sectionDivider: number; +} + +function listMetrics(rowInset: number): CommandCenterListMetrics { + const line = COMMAND_CENTER_LINE_HEIGHT; + return { + singleLineRow: rowInset + line.singleLine + rowInset, + twoLineRow: rowInset + line.title + line.subtitle + rowInset, + sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP, + sectionDivider: + COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP, + }; +} + +/** + * Desktop is dense for a pointer. Compact keeps single-line rows at the 44pt touch height that + * `density.rowHeight` uses on compact layouts. + */ +export const COMMAND_CENTER_LIST_METRICS = { + desktop: listMetrics(SPACING[1.5]), + compact: listMetrics(SPACING[3]), +} as const satisfies Record; diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index 4f6a43d704..fe7bec5897 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CommandCenterContribution } from "./contributions"; +import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics"; import { buildContributionSections, filterAndRankBuiltInResults, @@ -101,17 +102,30 @@ function autoModeChoice(): CommandCenterContribution { }); } -function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult { +function workspace( + id: string, + title = id, + subtitle = "host", + activityAt: Date | null = null, +): CommandCenterWorkspaceResult { return { kind: "workspace", + serverId: "server", + workspaceId: "workspace", id, title, subtitle, + project: { viewKey: "project", name: "project", iconDataUri: null }, changeRequestNumber: null, + activityAt, run: () => undefined, }; } +function minutesAgo(minutes: number): Date { + return new Date(Date.UTC(2026, 9, 18, 12, 0) - minutes * 60_000); +} + function sectionResultIds(sections: ReturnType): string[] { const ids: string[] = []; for (const section of sections) { @@ -120,6 +134,8 @@ function sectionResultIds(sections: ReturnType return ids; } +const metrics = COMMAND_CENTER_LIST_METRICS.desktop; + describe("Command Center result projection", () => { it("query-gates model choices and creates one flat row index", () => { const contributions = [ @@ -130,16 +146,19 @@ describe("Command Center result projection", () => { expect(sectionResultIds(emptySections)).toEqual(["settings"]); const sections = buildContributionSections(contributions, "o"); - const projection = projectCommandCenterRows([ - ...sections, - { - id: "workspaces", - band: PINNED_SECTION_BAND, - rank: 2, - title: "Workspaces", - results: [workspace("workspace:1")], - }, - ]); + const projection = projectCommandCenterRows( + [ + ...sections, + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("workspace:1")], + }, + ], + metrics, + ); expect(projection.rows.map((row) => row.key)).toEqual([ "section:models", "opus", @@ -147,7 +166,12 @@ describe("Command Center result projection", () => { "workspace:1", ]); expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3); - expect(projection.offsets).toEqual([0, 32, 68, 117]); + expect(projection.offsets).toEqual([ + 0, + metrics.sectionTitle, + metrics.sectionTitle + metrics.singleLineRow, + metrics.sectionTitle + metrics.singleLineRow + metrics.sectionDivider + metrics.sectionTitle, + ]); }); it("shows query-only actions only when their group matches a search", () => { @@ -177,15 +201,18 @@ describe("Command Center result projection", () => { it("keeps keyboard selection aligned with rows beyond the render window", () => { const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`)); - const projection = projectCommandCenterRows([ - { - id: "workspaces", - band: PINNED_SECTION_BAND, - rank: 0, - title: "Workspaces", - results: workspaces, - }, - ]); + const projection = projectCommandCenterRows( + [ + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 0, + title: "Workspaces", + results: workspaces, + }, + ], + metrics, + ); let activeId: string | null = workspaces[0].id; for (let index = 0; index < 150; index += 1) { @@ -195,7 +222,7 @@ describe("Command Center result projection", () => { const targetId = workspaces[150].id; expect(activeId).toBe(targetId); expect(projection.rowIndexByResultId.get(targetId)).toBe(151); - expect(projection.offsets[151]).toBe(32 + 150 * 56); + expect(projection.offsets[151]).toBe(metrics.sectionTitle + 150 * metrics.twoLineRow); }); }); @@ -248,7 +275,7 @@ describe("Command Center relevance ordering", () => { schedulesAction(), action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }), ]; - const projection = projectCommandCenterRows(buildContributionSections(shuffled, "")); + const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""), metrics); // Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature. expect(projection.selectableResults.map((result) => result.id)).toEqual([ @@ -301,16 +328,19 @@ describe("Command Center relevance ordering", () => { }); it("keeps Workspaces below contributions even on a perfect match", () => { - const projection = projectCommandCenterRows([ - ...buildContributionSections([schedulesAction()], "auto"), - { - id: "workspaces", - band: PINNED_SECTION_BAND, - rank: 2, - title: "Workspaces", - results: [workspace("auto")], - }, - ]); + const projection = projectCommandCenterRows( + [ + ...buildContributionSections([schedulesAction()], "auto"), + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("auto")], + }, + ], + metrics, + ); expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]); }); @@ -324,7 +354,7 @@ describe("Command Center relevance ordering", () => { title: "Workspaces", results: [workspace("auto")], }; - const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]); + const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()], metrics); expect(projection.selectableResults.map((result) => result.id)).toEqual([ "mode:auto", @@ -461,15 +491,19 @@ describe("filterAndRankBuiltInResults", () => { visible: [row.title, row.subtitle], hidden: [], }); - const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => - left.title.localeCompare(right.title); - it("caps an empty query and preserves the caller's order", () => { - const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`)); + it("shows the five most recent rows for an empty query", () => { + const rows = Array.from({ length: 8 }, (_, index) => + workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)), + ); - expect( - filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id), - ).toEqual(["w0", "w1", "w2", "w3", "w4"]); + expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([ + "w7", + "w6", + "w5", + "w4", + "w3", + ]); }); it("puts the best match first even when it sorts last alphabetically", () => { @@ -480,9 +514,9 @@ describe("filterAndRankBuiltInResults", () => { ]; // Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design. - expect( - filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id), - ).toEqual(["c"]); + expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([ + "c", + ]); }); it("ranks a title match above a match that only the hidden field reached", () => { @@ -492,24 +526,41 @@ describe("filterAndRankBuiltInResults", () => { ? { visible: [row.title], hidden: ["deploy"] } : { visible: [row.title], hidden: [] }; - expect( - filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id), - ).toEqual(["title-hit", "hidden-hit"]); + expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([ + "title-hit", + "hidden-hit", + ]); }); - it("breaks an equal score with the caller's tiebreak, not with match order", () => { - // Both titles are an exact prefix match for "task", so only the tiebreak separates them. - const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")]; + it("orders rows in the same match tier by recency, not by where the word sits", () => { + // Both are whole-word matches for "usage"; the older one finds it earlier in the title. + const rows = [ + workspace("older", "Deliver sidebar usage", "host", minutesAgo(60)), + workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)), + ]; - expect( - filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id), - ).toEqual(["first", "second"]); + expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([ + "newer", + "older", + ]); + }); + + it("keeps a better match tier above a more recent weaker match", () => { + const rows = [ + workspace("subsequence", "Fix stuck toast", "fix/updating-messages", minutesAgo(1)), + workspace("whole-word", "Usage hover card", "host", minutesAgo(600)), + ]; + + expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([ + "whole-word", + "subsequence", + ]); }); it("drops a row when one token matches nothing", () => { const rows = [workspace("a", "Label as Design")]; - expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]); + expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]); }); }); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index 8f3528cd2c..52034ca893 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -5,6 +5,7 @@ import { scoreTextFields, } from "@getpaseo/protocol/search/text-match"; import type { CommandCenterContribution } from "./contributions"; +import type { CommandCenterListMetrics } from "./list-metrics"; /** Sections that reorder by how well they match the query. */ export const CONTRIBUTION_SECTION_BAND = 0; @@ -43,13 +44,23 @@ export function compareCommandCenterScores( return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match); } +export interface CommandCenterWorkspaceProject { + viewKey: string; + name: string; + iconDataUri: string | null; +} + export interface CommandCenterWorkspaceResult { kind: "workspace"; id: string; title: string; subtitle: string; + serverId: string; + workspaceId: string; + project: CommandCenterWorkspaceProject; /** PR/MR number backing this workspace, so a number query can match it exactly. */ changeRequestNumber: number | null; + activityAt: Date | null; run(): void; } @@ -59,6 +70,7 @@ export interface CommandCenterAgentResult { agent: AggregatedAgent; title: string; subtitle: string; + activityAt: Date | null; run(): void; } @@ -169,13 +181,12 @@ function contributionSearchFields( return { visible, hidden: contribution.keywords }; } -function resultHeight(result: CommandCenterResult): number { - if (result.kind === "workspace" || result.kind === "agent") return 56; - if (result.kind === "file") return 36; - if (result.contribution.presentation.kind === "action") { - return result.contribution.presentation.subtitle ? 56 : 36; - } - return 36; +/** Whether the row draws a second, subtitle line under its title. */ +function hasSubtitleLine(result: CommandCenterResult): boolean { + if (result.kind === "workspace" || result.kind === "agent") return true; + if (result.kind === "file") return false; + const presentation = result.contribution.presentation; + return presentation.kind === "action" && Boolean(presentation.subtitle); } /** @@ -203,22 +214,42 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine /** Rows shown when nothing has been typed, per pinned category. */ const DEFAULT_CATEGORY_RESULT_LIMIT = 5; +/** A built-in row (workspace or agent) that orders by how recently it saw activity. */ +export interface CommandCenterRecentRow { + activityAt: Date | null; +} + +/** Most recent first. A row with no timestamp sorts after every row that has one. */ +export function compareByRecency(left: CommandCenterRecentRow, right: CommandCenterRecentRow) { + return (right.activityAt?.getTime() ?? 0) - (left.activityAt?.getTime() ?? 0); +} + +/** + * Rank built-in rows by the kind of match they made: which fields matched, then the matcher's + * tier. Offset and spread are deliberately left out. They only say where in the title the word + * happened to sit, so "usage" in "Deliver sidebar usage" and in "expose usage data" would be + * ordered by sentence position. Leaving them out makes those rows equal, and recency decides. + */ +export function compareBuiltInScores(left: CommandCenterScore, right: CommandCenterScore): number { + return left.fieldRank - right.fieldRank || left.match.tier - right.match.tier; +} + /** * Filter and rank already-built rows. Rows in, rows out — building them needs translations, * navigation callbacks and the project tree, none of which matching cares about, so that stays * with the caller and this stays testable without mounting React. * - * `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for - * agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one - * that matched exactly as well. + * Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty + * query, go most recent first. */ -export function filterAndRankBuiltInResults( +export function filterAndRankBuiltInResults( rows: readonly Row[], query: string, fieldsOf: (row: Row) => CommandCenterSearchFields, - tiebreak: (left: Row, right: Row) => number, ): Row[] { - if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); + if (!query.trim()) { + return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); + } const scored: { row: Row; score: CommandCenterScore }[] = []; for (const row of rows) { @@ -227,7 +258,7 @@ export function filterAndRankBuiltInResults( } scored.sort( (left, right) => - compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row), + compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row), ); return scored.map((entry) => entry.row); } @@ -293,6 +324,7 @@ export function buildContributionSections( export function projectCommandCenterRows( sections: readonly CommandCenterResultSection[], + metrics: CommandCenterListMetrics, ): CommandCenterListProjection { const populated = sections .filter((section) => section.results.length > 0) @@ -305,10 +337,8 @@ export function projectCommandCenterRows( for (const [sectionIndex, section] of populated.entries()) { const divider = sectionIndex > 0; - let sectionHeight = 0; - if (section.title && divider) sectionHeight = 49; - if (section.title && !divider) sectionHeight = 32; - if (!section.title && divider) sectionHeight = 17; + const sectionHeight = + (divider ? metrics.sectionDivider : 0) + (section.title ? metrics.sectionTitle : 0); if (sectionHeight > 0) { offsets.push(offset); rows.push({ @@ -321,7 +351,7 @@ export function projectCommandCenterRows( offset += sectionHeight; } for (const result of section.results) { - const height = resultHeight(result); + const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow; offsets.push(offset); rowIndexByResultId.set(result.id, rows.length); rows.push({ kind: "result", key: result.id, result, height }); diff --git a/packages/app/src/command-center/workspace-contributions.ts b/packages/app/src/command-center/workspace-contributions.ts index afc484de57..538905cb4a 100644 --- a/packages/app/src/command-center/workspace-contributions.ts +++ b/packages/app/src/command-center/workspace-contributions.ts @@ -1,4 +1,4 @@ -import type { GitAction, GitActions } from "@/git/policy"; +import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { WorkspacePanelPlacement, @@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions( ...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []), ]; - const primary = source.gitActions.primary; - for (const [index, action] of source.gitActions.secondary.entries()) { - if (action.id === primary?.id) continue; + for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) { contributions.push(buildGitContribution(source, action, 100 + index, "query")); } diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts index 1008604bec..8864da5ea8 100644 --- a/packages/app/src/command-center/workspace-file-search.ts +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { usePanelStore } from "@/stores/panel-store"; +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; @@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string openWorkspaceFileFromExplorer({ filePath: path, persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), - closeExplorerAfterOpen: true, - showMobileAgent: usePanelStore.getState().showMobileAgent, openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) => useWorkspaceLayoutStore.getState().openTab({ workspaceKey, @@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string intent: "reveal", placement, }), - focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, + revealWorkspaceTab, }); }, [serverId, workspaceId], diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 70789e5353..cd9d008c55 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -41,7 +41,7 @@ import { findPaneById, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; -import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { @@ -188,6 +188,7 @@ export function useWorkspaceCommandCenterActions(): void { workspaceDirectory: workspace.workspaceDirectory ?? null, currentBranch: workspace.gitRuntime?.currentBranch ?? null, pinnedAt: workspace.pinnedAt ?? null, + setupStatus: workspace.setupStatus ?? null, labels: workspace.labels ?? [], })); const cwd = useWorkspaceDirectory(serverId, workspaceId); @@ -195,12 +196,10 @@ export function useWorkspaceCommandCenterActions(): void { const isPinned = fields?.pinnedAt != null; const isCompact = useIsCompactFormFactor(); const canPin = useHostFeature(serverId, "workspacePinning"); - const persistenceKey = - serverId && fields - ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id }) - : null; - const canShowSetup = useWorkspaceSetupStore((state) => - shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null), + const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation"); + const canShowSetup = shouldShowWorkspaceSetup( + fields?.setupStatus ?? null, + supportsSetupObservation, ); const { overrides } = useKeyboardShortcutOverrides(); const { gitActions, isGit } = useGitActions({ diff --git a/packages/app/src/command-center/workspace-search.test.ts b/packages/app/src/command-center/workspace-search.test.ts index 2cdae53dd2..b43e9edfbd 100644 --- a/packages/app/src/command-center/workspace-search.test.ts +++ b/packages/app/src/command-center/workspace-search.test.ts @@ -7,21 +7,24 @@ function workspace(input: { title?: string; subtitle?: string; changeRequestNumber?: number | null; + activityAt?: Date | null; }): CommandCenterWorkspaceResult { return { kind: "workspace", + serverId: "server", + workspaceId: "workspace", id: input.id, title: input.title ?? input.id, subtitle: input.subtitle ?? "", + project: { viewKey: "project", name: "project", iconDataUri: null }, changeRequestNumber: input.changeRequestNumber ?? null, + activityAt: input.activityAt ?? null, run: () => undefined, }; } function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] { - return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map( - (row) => row.id, - ); + return filterAndRankWorkspaces(rows, query).map((row) => row.id); } describe("filterAndRankWorkspaces", () => { @@ -79,4 +82,18 @@ describe("filterAndRankWorkspaces", () => { const text = workspace({ id: "a-text", title: "Migration 42" }); expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]); }); + + it("orders workspaces in the same match tier by recency", () => { + const older = workspace({ + id: "older", + title: "Usage hover card", + activityAt: new Date("2026-10-01T00:00:00Z"), + }); + const newer = workspace({ + id: "newer", + title: "Deliver sidebar usage", + activityAt: new Date("2026-10-17T00:00:00Z"), + }); + expect(ids([older, newer], "usage")).toEqual(["newer", "older"]); + }); }); diff --git a/packages/app/src/command-center/workspace-search.ts b/packages/app/src/command-center/workspace-search.ts index 317d1f1561..40c7c34d01 100644 --- a/packages/app/src/command-center/workspace-search.ts +++ b/packages/app/src/command-center/workspace-search.ts @@ -1,5 +1,6 @@ import { - compareCommandCenterScores, + compareBuiltInScores, + compareByRecency, filterAndRankBuiltInResults, scoreSearchFields, type CommandCenterScore, @@ -33,29 +34,22 @@ function searchFields(workspace: CommandCenterWorkspaceResult) { return { visible: [workspace.title, workspace.subtitle], hidden: [] }; } -function compareScoredWorkspaces( - left: ScoredWorkspace, - right: ScoredWorkspace, - tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number, -): number { +function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number { if (left.changeRequestHit !== right.changeRequestHit) { return left.changeRequestHit ? -1 : 1; } if (left.score && right.score) { - const scoreDelta = compareCommandCenterScores(left.score, right.score); + const scoreDelta = compareBuiltInScores(left.score, right.score); if (scoreDelta !== 0) return scoreDelta; } - return tiebreak(left.workspace, right.workspace); + return compareByRecency(left.workspace, right.workspace); } export function filterAndRankWorkspaces( workspaces: readonly CommandCenterWorkspaceResult[], query: string, - tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number, ): CommandCenterWorkspaceResult[] { - if (!query.trim()) { - return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak); - } + if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields); const changeRequestNumber = parseChangeRequestQuery(query); const matches: ScoredWorkspace[] = []; @@ -65,6 +59,6 @@ export function filterAndRankWorkspaces( changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber; if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit }); } - matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak)); + matches.sort(compareScoredWorkspaces); return matches.map((match) => match.workspace); } diff --git a/packages/app/src/components/adaptive-modal-sheet.test.tsx b/packages/app/src/components/adaptive-modal-sheet.test.tsx new file mode 100644 index 0000000000..96e8fba9d5 --- /dev/null +++ b/packages/app/src/components/adaptive-modal-sheet.test.tsx @@ -0,0 +1,33 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Text } from "react-native"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SheetBottomInset } from "./adaptive-modal-sheet"; + +beforeEach(() => vi.stubGlobal("React", React)); + +const SCREEN_INSETS = { top: 24, right: 0, bottom: 34, left: 0 }; + +function BottomInsetProbe() { + return {String(useSafeAreaInsets().bottom)}; +} + +describe("SheetBottomInset", () => { + it("pads the bottom inset once and leaves none for its content", () => { + render( + + + + + , + ); + + expect(screen.getByTestId("sheet-body").style.paddingBottom).toBe("34px"); + // Content that pads the inset itself, like the composer dock, must not pad it again. + expect(screen.getByTestId("content-bottom-inset").textContent).toBe("0"); + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d4d833e3f1..4ddf872daa 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -3,7 +3,7 @@ import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Keyboard, Pressable, Text, View } from "react-native"; -import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; +import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -12,7 +12,12 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import { + BottomSheetHandle, + useBottomSheetGestureHandlers, + type BottomSheetBackgroundProps, +} from "@gorhom/bottom-sheet"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native"; import { @@ -25,7 +30,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee import { ScrollView } from "@/components/ui/scroll-view"; import { isWeb } from "@/constants/platform"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { AdaptiveTextInput } from "@/components/adaptive-text-input"; export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input"; @@ -40,6 +45,9 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6; // glyph should line up with the X must reach the same rail. export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2; +/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */ +export const FULL_SHEET_SNAP_POINTS = ["92%"]; + export interface SheetHeaderSearch { onChange: (value: string) => void; onFocus?: () => void; @@ -66,7 +74,7 @@ export interface SheetHeader { } const SCROLL_CONTENT_GROW = { flexGrow: 1 }; -const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; +const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill }; const NATIVE_DIALOG_SNAP_POINTS = ["100%"]; const styles = StyleSheet.create((theme) => ({ @@ -77,7 +85,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: "transparent", }, desktopOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, backgroundColor: "rgba(0,0,0,0.55)", justifyContent: "center", alignItems: "center", @@ -244,32 +252,55 @@ const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({ indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, })); -const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 }; - -/** - * The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children - * join the handle, so a header passed here drags the sheet like the indicator does. That is how a - * sheet whose content scrolls on its own keeps its header draggable without content panning. - */ +/** The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. */ export function SheetHandle({ - children, // Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator. indicatorStyle: _indicatorStyle, ...props }: ComponentProps) { - if (!children) return ; - // The children carry their own semantics; the handle's slider role would swallow them on web. + return ; +} + +/** + * Content that drags the sheet the way its handle does. A sheet whose body scrolls on its own + * turns content panning off and wraps its header in this, so the header still pulls it down. + * Render it inside the sheet's content. + */ +export function SheetDragArea({ children }: { children: ReactNode }) { + const { handlePanGestureHandler } = useBottomSheetGestureHandlers(); + const gesture = useMemo( + () => + Gesture.Pan() + .shouldCancelWhenOutside(false) + .runOnJS(false) + .onStart(handlePanGestureHandler.handleOnStart) + .onChange(handlePanGestureHandler.handleOnChange) + .onEnd(handlePanGestureHandler.handleOnEnd) + .onFinalize(handlePanGestureHandler.handleOnFinalize), + [handlePanGestureHandler], + ); return ( - - {children} - + + {children} + + ); +} + +/** + * A compact sheet's content area that reaches the bottom of the screen. It owns the system + * navigation inset: it pads it once and tells its content none is left, so content that pads the + * inset itself, like the composer dock, sits the same in a sheet as in a tab. + */ +export function SheetBottomInset({ style, children, ...props }: ViewProps) { + const insets = useSafeAreaInsets(); + const contentInsets = useMemo(() => ({ ...insets, bottom: 0 }), [insets]); + const insetStyle = useMemo(() => [style, { paddingBottom: insets.bottom }], [style, insets]); + return ( + + + {children} + + ); } @@ -488,6 +519,76 @@ export interface AdaptiveModalSheetProps { contextBridge?: ContextBridge | null; } +/** + * The compact sheet's body. It renders on the far side of the sheet's portal so it reads the + * screen's bottom inset, not the one left where the sheet was opened: inside a sheet tab, none is. + */ +function CompactSheetBody({ + header, + onClose, + testID, + footer, + isKeyboardVisible, + scrollable, + bodyStyle, + contentStyle, + children, +}: { + header: SheetHeader; + onClose: () => void; + testID?: string; + footer: ReactNode; + isKeyboardVisible: boolean; + scrollable: boolean; + bodyStyle?: StyleProp; + contentStyle?: StyleProp; + children: ReactNode; +}) { + const insets = useSafeAreaInsets(); + const { contentPaddingBottom = 0, footerPaddingBottom = 0 } = getCompactSheetSafeAreaPadding({ + isCompact: true, + isKeyboardVisible, + hasFooter: Boolean(footer), + safeAreaBottom: insets.bottom, + }); + // Safe-area clearance is a separate layer: it must not replace the caller's + // padding (including an explicit zero), and the footer owns it when present. + const bodyClearanceStyle = { paddingBottom: contentPaddingBottom }; + const footerClearanceStyle = useMemo( + () => ({ paddingBottom: footerPaddingBottom }), + [footerPaddingBottom], + ); + const footerView = useMemo( + () => (footer ? {footer} : null), + [footer, footerClearanceStyle], + ); + return ( + + + + {scrollable ? ( + + + {children} + + + ) : ( + + + {children} + + + )} + + + ); +} + export function AdaptiveModalSheet({ header, visible, @@ -508,34 +609,11 @@ export function AdaptiveModalSheet({ }: AdaptiveModalSheetProps) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardVisibility(visible); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); - const compactSafeAreaPadding = useMemo( - () => - getCompactSheetSafeAreaPadding({ - isCompact: isMobile, - isKeyboardVisible, - hasFooter: Boolean(footer), - safeAreaBottom: insets.bottom, - }), - [footer, insets.bottom, isKeyboardVisible, isMobile], - ); - // Safe-area clearance is a separate layer: it must not replace the caller's - // padding (including an explicit zero), and the footer owns it when present. - const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 }; - const footerClearanceStyle = useMemo( - () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), - [compactSafeAreaPadding.footerPaddingBottom], - ); const footerView = useMemo( - () => - footer ? ( - - {footer} - - ) : null, - [footer, footerClearanceStyle, footerContainerStyle], + () => (footer ? {footer} : null), + [footer, footerContainerStyle], ); useEffect(() => { if (!isWeb && visible) { @@ -614,32 +692,6 @@ export function AdaptiveModalSheet({ }, [visible, isMobile, onDismiss, shouldRenderWeb]); if (isMobile) { - const sheetContent = ( - <> - - - {scrollable ? ( - - - {children} - - - ) : ( - - - {children} - - - )} - - - ); - return ( - {sheetContent} + + {children} + ); } diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index c331f237c5..bcd9bd541a 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,6 @@ +import { useAddHost } from "@/hosts/add-host"; +import { Modal } from "@/components/ui/modal"; +import { usePanelStore } from "@/stores/panel-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; @@ -21,14 +24,7 @@ import { type ComponentType, type RefObject, } from "react"; -import { - Modal, - Pressable, - ScrollView, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { EditingTextInput as TextInput, type EditingTextInputHandle, @@ -97,7 +93,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store"; import type { Theme } from "@/styles/theme"; import { shortenPath } from "@/utils/shorten-path"; -import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; interface AddProjectFlowProps { request: AddProjectFlowRequest; @@ -330,6 +326,7 @@ function setPageStatus( // The product flow is intentionally one cohesive page-stack state machine. // eslint-disable-next-line complexity export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { + const addHost = useAddHost(); const hosts = useHosts(); const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); @@ -474,6 +471,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const openNewWorkspaceForProject = useCallback( (serverId: string, project: WorkspaceProjectDescriptorPayload) => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push( buildNewWorkspaceRoute({ serverId, @@ -620,8 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { icon: Plus, testID: "add-project-flow-add-host", select: () => { + // The native project Modal must release its window before the root sheet opens. onClose(); - router.push(buildSettingsAddHostRoute(Date.now())); + addHost(); }, }); } @@ -720,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { directoryPaths, githubQuery.data, host, + addHost, onClose, openAddedProject, page, @@ -1007,7 +1007,7 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[12], }, backdrop: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, backgroundColor: "rgba(0, 0, 0, 0.5)", }, panel: { diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 7da36742ae..8921102f26 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,8 +1,8 @@ +import { Modal } from "@/components/ui/modal"; import { View, Text, Pressable, - Modal, RefreshControl, FlatList, type ListRenderItem, @@ -48,6 +48,8 @@ const DATE_SECTION_ORDER = [ "older", ] as const satisfies readonly DateSectionKey[]; +const HISTORY_MATCH_OPTIONS = { typoTolerant: true } as const; + type FlatListItem = | { type: "header"; key: string; section: DateSectionKey } | { type: "agent"; key: string; agent: AggregatedAgent }; @@ -190,10 +192,11 @@ function SessionRow({ const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const ranges = useMemo( () => ({ - workspace: findHighlightRanges(search ?? "", workspaceName), - title: findHighlightRanges(search ?? "", agent.title ?? ""), - branch: findHighlightRanges(search ?? "", branch), - project: findHighlightRanges(search ?? "", projectName), + // History search matches typo-tolerantly on the daemon, so the marks do too. + workspace: findHighlightRanges(search ?? "", workspaceName, HISTORY_MATCH_OPTIONS), + title: findHighlightRanges(search ?? "", agent.title ?? "", HISTORY_MATCH_OPTIONS), + branch: findHighlightRanges(search ?? "", branch, HISTORY_MATCH_OPTIONS), + project: findHighlightRanges(search ?? "", projectName, HISTORY_MATCH_OPTIONS), }), [search, workspaceName, agent.title, branch, projectName], ); diff --git a/packages/app/src/components/appearance-style-boundary.test.ts b/packages/app/src/components/appearance-style-boundary.test.ts index da6fb85c45..5e73ce36f9 100644 --- a/packages/app/src/components/appearance-style-boundary.test.ts +++ b/packages/app/src/components/appearance-style-boundary.test.ts @@ -20,54 +20,53 @@ describe("appearanceStyleBoundaryKey", () => { // This is a placement contract against the production JSX, not a simulated native mount. // Native Gesture Handler/Fabric lifetime behavior is verified on the release app. describe("retained native gesture host appearance placement", () => { - it.each([ - ["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]], - [ - "./compact-explorer-sidebar-host.tsx", - ["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"], - ], - ] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => { - const source = ts.createSourceFile( - path, - readFileSync(new URL(path, import.meta.url), "utf8"), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX, - ); - let hostsFound = 0; - let webKeysFound = 0; - function visit(node: ts.Node) { - let name: string | undefined; - if (ts.isIdentifier(node)) name = node.text; - if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); - if (name && hosts.some((host) => host === name)) { - hostsFound++; - let webOnly = false; - let keyed = false; - for ( - let child = node, parent = node.parent; - parent; - child = parent, parent = parent.parent - ) { - if ( - ts.isIfStatement(parent) && - parent.expression.getText(source) === "isWeb" && - parent.thenStatement === child - ) - webOnly = true; - if ( - ts.isJsxElement(parent) && - parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" - ) - keyed = true; + it.each([["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"], true]] as const)( + "keeps native hosts outside appearance keys in %s", + (path, hosts, hasWebKeys) => { + const source = ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let hostsFound = 0; + let webKeysFound = 0; + function visit(node: ts.Node) { + let name: string | undefined; + if (ts.isIdentifier(node)) name = node.text; + if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); + if (name && hosts.some((host) => host === name)) { + hostsFound++; + let webOnly = false; + let keyed = false; + for ( + let child = node, parent = node.parent; + parent; + child = parent, parent = parent.parent + ) { + if ( + ts.isIfStatement(parent) && + parent.expression.getText(source) === "isWeb" && + parent.thenStatement === child + ) + webOnly = true; + if ( + ts.isJsxElement(parent) && + parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" + ) + keyed = true; + } + if (keyed && webOnly) webKeysFound++; + expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe( + false, + ); } - if (keyed && webOnly) webKeysFound++; - expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false); + ts.forEachChild(node, visit); } - ts.forEachChild(node, visit); - } - visit(source); - expect(hostsFound).toBeGreaterThan(0); - expect(webKeysFound).toBeGreaterThan(0); - }); + visit(source); + expect(hostsFound).toBeGreaterThan(0); + if (hasWebKeys) expect(webKeysFound).toBeGreaterThan(0); + }, + ); }); diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index ef5aa1833a..82fe6b139f 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -72,6 +72,7 @@ vi.mock("react-i18next", () => ({ })); vi.mock("react-native-safe-area-context", () => ({ + SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children, useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), })); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index 3cf2c5203f..08c6042bf8 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Modal, Pressable, Text, View } from "react-native"; +import { Modal } from "@/components/ui/modal"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Pressable, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; @@ -28,7 +29,6 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 }; export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) { const { t } = useTranslation(); - const insets = useSafeAreaInsets(); const metadata = source?.type === "attachment" ? source.metadata : null; const attachmentUrl = useAttachmentPreviewUrl(metadata); const url = source?.type === "uri" ? source.uri : attachmentUrl; @@ -56,18 +56,6 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) onKeyDown: handleWebOverlayKeyDown, }); - const contentLayerStyle = useMemo( - () => [ - styles.contentLayer, - { - paddingTop: insets.top, - paddingRight: insets.right, - paddingBottom: insets.bottom, - paddingLeft: insets.left, - }, - ], - [insets.bottom, insets.left, insets.right, insets.top], - ); const actions = useMemo( () => [ { @@ -91,17 +79,19 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) return ( - - - - - + {/* The modal is its own window: its insets are the window's, not the ones left where the + image was opened (inside a sheet tab, the bottom one is already spent). */} + + + + + {hasError ? ( {t("message.attachments.imageLoadFailed")} ) : ( @@ -117,15 +107,38 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) uri={url} /> )} - + - - + + ); } +function LightboxImageArea({ children }: { children: ReactNode }) { + const insets = useSafeAreaInsets(); + const style = useMemo( + () => [ + styles.contentLayer, + { + paddingTop: insets.top, + paddingRight: insets.right, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + }, + ], + [insets.bottom, insets.left, insets.right, insets.top], + ); + return ( + + + {children} + + + ); +} + const styles = StyleSheet.create((theme) => ({ root: { flex: 1, diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.test.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.test.ts deleted file mode 100644 index 0e55872c0a..0000000000 --- a/packages/app/src/components/compact-explorer-sidebar-host-state.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveCompactExplorerSidebarHostModel, - type CompactExplorerSidebarHostModel, -} from "@/components/compact-explorer-sidebar-host-state"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; - -function createWorkspace( - input: Partial & Pick, -): WorkspaceDescriptor { - return { - id: input.id, - projectId: input.projectId ?? "project-1", - projectDisplayName: input.projectDisplayName ?? "Project 1", - projectRootPath: input.projectRootPath ?? "/repo", - workspaceDirectory: input.workspaceDirectory ?? "/repo", - projectKind: input.projectKind ?? "git", - workspaceKind: input.workspaceKind ?? "local_checkout", - name: input.name ?? "main", - status: input.status ?? "done", - archivingAt: input.archivingAt ?? null, - statusEnteredAt: null, - diffStat: input.diffStat ?? null, - scripts: input.scripts ?? [], - }; -} - -function createModel( - overrides: Partial = {}, -): CompactExplorerSidebarHostModel { - return { - serverId: overrides.serverId ?? "server-1", - workspaceId: overrides.workspaceId ?? "workspace-a", - persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a", - workspaceRoot: overrides.workspaceRoot ?? "/repo/a", - isGit: overrides.isGit ?? true, - }; -} - -describe("resolveCompactExplorerSidebarHostModel", () => { - it("retains the last workspace root for the same active selection while the workspace reloads", () => { - const previous = createModel(); - - const result = resolveCompactExplorerSidebarHostModel({ - previous, - selection: { serverId: "server-1", workspaceId: "workspace-a" }, - workspace: null, - isGit: false, - }); - - expect(result).toEqual({ - serverId: "server-1", - workspaceId: "workspace-a", - persistenceKey: "server-1:workspace-a", - workspaceRoot: "/repo/a", - isGit: true, - }); - }); - - it("switches ownership to the active workspace instead of leaking the previous one", () => { - const previous = createModel(); - - const result = resolveCompactExplorerSidebarHostModel({ - previous, - selection: { serverId: "server-1", workspaceId: "workspace-b" }, - workspace: null, - isGit: false, - }); - - expect(result).toEqual({ - serverId: "server-1", - workspaceId: "workspace-b", - persistenceKey: "server-1:workspace-b", - workspaceRoot: "", - isGit: false, - }); - }); - - it("does not retain a previous owner when there is no active workspace selection", () => { - const result = resolveCompactExplorerSidebarHostModel({ - previous: createModel(), - selection: null, - workspace: null, - isGit: false, - }); - - expect(result).toBeNull(); - }); - - it("uses the current workspace directory when it is available", () => { - const result = resolveCompactExplorerSidebarHostModel({ - previous: null, - selection: { serverId: "server-1", workspaceId: "workspace-a" }, - workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }), - isGit: true, - }); - - expect(result).toEqual({ - serverId: "server-1", - workspaceId: "workspace-a", - persistenceKey: "server-1:workspace-a", - workspaceRoot: "/repo/current", - isGit: true, - }); - }); -}); diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.ts deleted file mode 100644 index 8d44a9d767..0000000000 --- a/packages/app/src/components/compact-explorer-sidebar-host-state.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; - -export interface CompactExplorerSidebarHostModel { - serverId: string; - workspaceId: string; - persistenceKey: string; - workspaceRoot: string; - isGit: boolean; -} - -interface ResolveCompactExplorerSidebarHostModelInput { - previous: CompactExplorerSidebarHostModel | null; - selection: ActiveWorkspaceSelection | null; - workspace: WorkspaceDescriptor | null; - isGit: boolean; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function resolveCompactExplorerSidebarHostModel( - input: ResolveCompactExplorerSidebarHostModelInput, -): CompactExplorerSidebarHostModel | null { - const serverId = trimNonEmpty(input.selection?.serverId); - const workspaceId = trimNonEmpty(input.selection?.workspaceId); - if (!serverId || !workspaceId) { - return null; - } - - const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!persistenceKey) { - return null; - } - - const previousForSelection = - input.previous && - input.previous.serverId === serverId && - input.previous.workspaceId === workspaceId - ? input.previous - : null; - - return { - serverId, - workspaceId, - persistenceKey, - workspaceRoot: - trimNonEmpty(input.workspace?.workspaceDirectory) ?? - previousForSelection?.workspaceRoot ?? - "", - isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit), - }; -} diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx deleted file mode 100644 index 2dae4b9042..0000000000 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type LayoutChangeEvent } from "react-native"; -import { GestureDetector } from "react-native-gesture-handler"; -import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { useWorkspace } from "@/stores/session-store-hooks"; -import { - CompactExplorerSidebar, - NativeExplorerSidebarDock, -} from "@/components/compact-explorer-sidebar"; -import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures"; -import { useIsMobilePanelActive } from "@/mobile-panels/provider"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { usePanelStore } from "@/stores/panel-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; -import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; -import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; -import { isWeb } from "@/constants/platform"; -import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache"; -import { - resolveCompactExplorerSidebarHostModel, - type CompactExplorerSidebarHostModel, -} from "@/components/compact-explorer-sidebar-host-state"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; - -interface CompactExplorerOpenGestureSurfaceProps { - children: ReactNode; - enabled: boolean; - onOpenExplorer: () => void; -} - -const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; - -function CompactExplorerOpenGestureSurface({ - children, - enabled, - onOpenExplorer, -}: CompactExplorerOpenGestureSurfaceProps) { - const explorerOpenGesture = useOpenFileExplorerGesture({ - enabled, - onOpen: onOpenExplorer, - }); - - return ( - - {children} - - ); -} - -function useActiveCompactExplorerSidebarModel( - enabled: boolean, -): CompactExplorerSidebarHostModel | null { - const selection = useActiveWorkspaceSelection(); - const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); - const isExplorerActive = useIsMobilePanelActive("file-explorer"); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const client = useHostRuntimeClient(selection?.serverId ?? ""); - const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); - const retainedModelRef = useRef(null); - const { checkoutQuery } = useWorkspaceCheckoutStatus({ - client, - isConnected, - isRouteFocused: enabled && selection !== null, - normalizedServerId: selection?.serverId ?? "", - normalizedWorkspaceId: selection?.workspaceId ?? "", - workspaceDirectory: workspace?.workspaceDirectory || null, - }); - const resolvedModel = useMemo( - () => - resolveCompactExplorerSidebarHostModel({ - previous: isExplorerActive ? retainedModelRef.current : null, - selection, - workspace, - isGit: checkoutQuery.data?.isGit ?? false, - }), - [checkoutQuery.data?.isGit, isExplorerActive, selection, workspace], - ); - - useEffect(() => { - if (!selection) { - retainedModelRef.current = null; - if (enabled && isExplorerActive) { - showMobileAgent(); - } - return; - } - if (!isExplorerActive) { - retainedModelRef.current = null; - return; - } - if (resolvedModel) { - retainedModelRef.current = resolvedModel; - } - }, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]); - - return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null; -} - -interface CompactExplorerSidebarHostProps { - children: ReactNode; - enabled: boolean; - presentation: "overlay" | "dock"; -} - -export function CompactExplorerSidebarHost({ - children, - enabled, - presentation, -}: CompactExplorerSidebarHostProps) { - const model = useActiveCompactExplorerSidebarModel(enabled); - const [containerWidth, setContainerWidth] = useState(0); - const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const openTab = useWorkspaceLayoutStore((state) => state.openTab); - const openWorkspaceTabInFocusedPane = useCallback( - (workspaceKey: string, target: WorkspaceTabTarget) => - openTab({ workspaceKey, target, intent: "reveal" }), - [openTab], - ); - const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); - - const handleOpenExplorer = useCallback(() => { - if (!model?.workspaceRoot) { - return; - } - openCompactFileExplorer({ - serverId: model.serverId, - cwd: model.workspaceRoot, - isGit: model.isGit, - }); - }, [model, openCompactFileExplorer]); - - const handleOpenFile = useCallback( - (filePath: string) => { - if (!model) { - return; - } - openWorkspaceFileFromExplorer({ - filePath, - persistenceKey: model.persistenceKey, - closeExplorerAfterOpen: presentation === "overlay", - showMobileAgent, - openWorkspaceTabInFocusedPane, - focusWorkspaceTab, - }); - }, - [focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent], - ); - - const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { - const nextWidth = event.nativeEvent.layout.width; - setContainerWidth((current) => (current === nextWidth ? current : nextWidth)); - }, []); - - const explorer = - enabled && model ? ( - - {presentation === "dock" ? ( - - ) : ( - - )} - - ) : null; - - let themedExplorer = explorer; - if (isWeb) { - themedExplorer = {explorer}; - } - - if (presentation === "dock") { - return ( - - {children} - {themedExplorer} - - ); - } - - return ( - <> - - {children} - - {themedExplorer} - - ); -} - -const styles = { - fill: { - flex: 1, - }, - row: { - flex: 1, - flexDirection: "row", - }, -} as const; diff --git a/packages/app/src/components/compact-explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx deleted file mode 100644 index d52a9eac36..0000000000 --- a/packages/app/src/components/compact-explorer-sidebar.tsx +++ /dev/null @@ -1,574 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, Text, Pressable } from "react-native"; -import { Gesture } from "react-native-gesture-handler"; -import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { scheduleOnRN } from "react-native-worklets"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; -import { X } from "lucide-react-native"; -import { useTranslation } from "react-i18next"; -import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel"; -import { - usePanelStore, - selectIsCompactFileExplorerOpen, - type ExplorerTab, -} from "@/stores/panel-store"; -import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; -import { useIsMobilePanelActive } from "@/mobile-panels/provider"; -import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { - HEADER_INNER_HEIGHT, - HEADER_INNER_HEIGHT_MOBILE, - HEADER_TOP_PADDING_MOBILE, - useIsCompactFormFactor, -} from "@/constants/layout"; -import { ChangesSurface } from "@/git/diff-pane"; -import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state"; -import { FileExplorerPane } from "./file-explorer-pane"; -import { useKeyboardShiftStyle } from "@/keyboard/shift"; -import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift"; -import { WindowChromeSafeArea } from "@/utils/desktop-window"; -import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel"; -import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; -import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability"; -import { PullRequestContent } from "@/panels/pull-request"; -import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; -import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; -import { - SIDEBAR_RESIZE_ACTIVATION_OFFSET, - SIDEBAR_RESIZE_FAIL_OFFSET, -} from "@/components/sidebar-resize-handle-layout"; -import { - EXPLORER_TAB_RAIL_INSET, - explorerSidebarCloseButtonLayout, - resolveExplorerSidebarWidth, -} from "@/components/explorer-sidebar-layout"; -import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; -import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; - -const ThemedX = withUnistyles(X); - -function logExplorerSidebar(_event: string, _details: Record): void {} - -interface ExplorerSidebarProps { - serverId: string; - workspaceId?: string | null; - workspaceRoot: string; - isGit: boolean; - onOpenFile?: (filePath: string) => void; -} - -interface ExplorerSidebarSharedState { - explorerTab: ExplorerTab; - handleTabPress: (tab: ExplorerTab) => void; -} - -function useExplorerSidebarSharedState({ - serverId, - workspaceRoot, - isGit, -}: Pick): ExplorerSidebarSharedState { - const explorerTab = usePanelStore((state) => state.explorerTab); - const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); - const handleTabPress = useCallback( - (tab: ExplorerTab) => { - setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); - }, - [isGit, serverId, setExplorerTabForCheckout, workspaceRoot], - ); - - return { explorerTab, handleTabPress }; -} - -export function CompactExplorerSidebar({ - serverId, - workspaceId, - workspaceRoot, - isGit, - onOpenFile, -}: ExplorerSidebarProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const isActive = useIsMobilePanelActive("file-explorer"); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ - serverId, - workspaceRoot, - isGit, - }); - const usePanelKeyboardPadding = shouldUseCompactExplorerKeyboardPadding({ isGit, explorerTab }); - const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ - mode: "padding", - enabled: usePanelKeyboardPadding, - }); - const { gesture: closeGesture } = useCloseFileExplorerGesture(); - - const handleClose = useCallback( - (reason: string) => { - logExplorerSidebar("handleClose", { - reason, - isOpen: isActive, - }); - showMobileAgent(); - }, - [isActive, showMobileAgent], - ); - - const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); - - const mobileSidebarStyle = useMemo( - () => [ - { - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, - paddingBottom: usePanelKeyboardPadding ? 0 : insets.bottom, - backgroundColor: theme.colors.surfaceSidebar, - }, - mobileKeyboardInsetStyle, - ], - [ - insets.bottom, - insets.top, - mobileKeyboardInsetStyle, - theme.colors.surfaceSidebar, - usePanelKeyboardPadding, - ], - ); - - return ( - - - - - - ); -} - -interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps { - persistenceKey: string; - containerWidth: number; -} - -export function NativeExplorerSidebarDock({ - serverId, - workspaceId, - workspaceRoot, - isGit, - onOpenFile, - persistenceKey, - containerWidth, -}: NativeExplorerSidebarDockProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const isOpen = usePanelStore(selectIsCompactFileExplorerOpen); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const storedWidth = useWorkspaceLayoutStore( - (state) => state.explorerSidebarWidthByWorkspace[persistenceKey], - ); - const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); - const visibleWidth = resolveExplorerSidebarWidth({ - requestedWidth: storedWidth, - containerWidth, - }); - const resizeWidth = useSharedValue(visibleWidth); - const startWidthRef = useRef(visibleWidth); - const [resizePressed, setResizePressed] = useState(false); - const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ - serverId, - workspaceRoot, - isGit, - }); - - useEffect(() => { - resizeWidth.value = visibleWidth; - }, [resizeWidth, visibleWidth]); - - const showResizeGrip = useCallback(() => setResizePressed(true), []); - const hideResizeGrip = useCallback(() => setResizePressed(false), []); - const commitWidth = useCallback( - (width: number) => resizeExplorerSidebar(persistenceKey, width), - [persistenceKey, resizeExplorerSidebar], - ); - const resizeGesture = useMemo( - () => - Gesture.Pan() - .enabled(true) - .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) - .onBegin(() => scheduleOnRN(showResizeGrip)) - .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) - .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) - .onStart((event) => { - startWidthRef.current = visibleWidth + event.translationX; - resizeWidth.value = visibleWidth; - }) - .onUpdate((event) => { - resizeWidth.value = resolveExplorerSidebarWidth({ - requestedWidth: startWidthRef.current - event.translationX, - containerWidth, - }); - }) - .onEnd(() => runOnJS(commitWidth)(resizeWidth.value)) - .onFinalize(() => scheduleOnRN(hideResizeGrip)), - [commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth], - ); - const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value })); - const dockStyle = useMemo( - () => [ - styles.nativeDock, - { - display: isOpen ? ("flex" as const) : ("none" as const), - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, - backgroundColor: theme.colors.surfaceSidebar, - }, - animatedWidthStyle, - ], - [animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar], - ); - const dockContentStyle = useMemo( - () => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }], - [theme.colors.border], - ); - - return ( - - - - - - - - - ); -} - -interface ExplorerTabButtonProps { - tab: ExplorerTab; - active: boolean; - label?: string; - onTabPress: (tab: ExplorerTab) => void; - testID: string; - children?: React.ReactNode; -} - -function ExplorerTabButton({ - tab, - active, - label, - onTabPress, - testID, - children, -}: ExplorerTabButtonProps) { - const isCompact = useIsCompactFormFactor(); - const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]); - const tabStyle = useMemo( - () => [styles.tab(isCompact), active && styles.tabActive], - [active, isCompact], - ); - const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]); - return ( - - {children} - {label !== undefined ? {label} : null} - - ); -} - -interface SidebarContentProps { - activeTab: ExplorerTab; - onTabPress: (tab: ExplorerTab) => void; - onClose: () => void; - serverId: string; - workspaceId?: string | null; - workspaceRoot: string; - isGit: boolean; - isOpen: boolean; - onOpenFile?: (filePath: string) => void; -} - -function ExplorerSidebarContent({ - activeTab, - onTabPress, - onClose, - serverId, - workspaceId, - workspaceRoot, - isGit, - isOpen, - onOpenFile, -}: SidebarContentProps) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact); - const closeButtonStyle = useMemo( - () => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }), - [closeButtonLayout.size], - ); - // The close glyph shares the trailing rail with the toolbar rows below it. - const headerRightSectionStyle = useMemo( - () => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }], - [closeButtonLayout.trailingPadding], - ); - const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({ - serverId, - cwd: workspaceRoot, - isGit, - requested: activeTab === "pr", - enabled: isOpen, - timelineEnabled: activeTab === "pr", - }); - const requestedTab: ExplorerTab = - !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; - const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab; - const prTabLabel = formatPrTabLabel(prPane.prNumber); - const availableTabs = useMemo(() => { - const tabs: ExplorerTab[] = isGit ? ["changes", "files"] : ["files"]; - if (isGit && showPrTab) tabs.push("pr"); - return tabs; - }, [isGit, showPrTab]); - const { mountedTabIds } = useMountedTabSet({ - activeTabId: resolvedTab, - allTabIds: availableTabs, - cap: availableTabs.length, - }); - - return ( - - {/* Header with tabs and close button */} - - - - {isGit && ( - - )} - - {isGit && showPrTab && ( - - - - )} - - - - - - - - - {/* Content based on active tab */} - - {mountedTabIds.has("changes") ? ( - - - - ) : null} - {mountedTabIds.has("files") ? ( - - - - ) : null} - {mountedTabIds.has("pr") ? ( - - - - ) : null} - - - ); -} - -function ChangedFilesPane({ - serverId, - workspaceId, - workspaceRoot, - isOpen, - onOpenFile, -}: Pick< - SidebarContentProps, - "serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile" ->) { - const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); - const [changesState, setChangesState] = useState(() => - changesStateSchema.parse(defaultChangesState), - ); - return ( - - ); -} - -function FilesPane({ - serverId, - workspaceId, - workspaceRoot, - onOpenFile, -}: Pick) { - const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); - return ( - - ); -} - -const PrTabContent = PullRequestContent; - -const styles = StyleSheet.create((theme) => ({ - nativeDock: { - position: "relative", - height: "100%", - minHeight: 0, - overflow: "hidden", - }, - nativeDockContent: { - position: "relative", - flex: 1, - minHeight: 0, - borderLeftWidth: 1, - }, - sidebarContent: { - flex: 1, - minHeight: 0, - overflow: "hidden", - }, - header: { - position: "relative", - height: { - xs: HEADER_INNER_HEIGHT_MOBILE, - md: HEADER_INNER_HEIGHT, - }, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - tabsContainer: (isCompact: boolean) => ({ - flexDirection: "row", - gap: theme.spacing[1], - // With the tab's own horizontal padding this puts the label on the pane's - // leading rail, the same way the desktop tab rail does. - paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2], - }), - tab: (isCompact: boolean) => ({ - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3], - borderRadius: theme.borderRadius.md, - }), - tabActive: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, - tabText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - tabTextActive: { - color: theme.colors.foreground, - }, - tabTextMuted: { - opacity: 0.8, - }, - headerRightSection: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - contentArea: { - flex: 1, - minHeight: 0, - }, -})); diff --git a/packages/app/src/components/connection-failure-copy.test.ts b/packages/app/src/components/connection-failure-copy.test.ts new file mode 100644 index 0000000000..de78ba43eb --- /dev/null +++ b/packages/app/src/components/connection-failure-copy.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; +import { buildConnectionFailureCopy } from "./connection-failure-copy"; + +const labels = { + hostRequired: "host required", + invalidPort: "invalid port", + invalidConnection: "invalid connection", + failedToConnect: (endpoint: string) => `Failed: ${endpoint}`, + noAdditionalDetails: (detail: string) => `No details: ${detail}`, + timedOut: "timeout", + refused: "refused", + hostNotFound: "DNS failure", + hostUnreachable: "unreachable", + tlsError: "TLS failure", + unableToConnect: "connection failure", +}; + +function failure(message: string) { + return buildConnectionFailureCopy({ + endpoint: "tcp://example.test:443?ssl=true", + error: new DaemonConnectionTestError(message, { + reason: "transport error", + lastError: message, + }), + labels, + }); +} + +describe("direct connection failure copy", () => { + it.each([ + "paseo-qa-nonexistent.invalid", + "ssl-paseo-qa.invalid", + "tls-paseo-qa.invalid", + "certificate-paseo-qa.invalid", + ])( + "identifies Android DNS failures for %s without treating the hostname as TLS evidence", + (host) => { + const message = `Unable to resolve host "${host}": No address associated with hostname`; + expect(failure(message)).toEqual({ + title: "Failed: tcp://example.test:443?ssl=true", + detail: labels.hostNotFound, + raw: `transport error — ${message}`, + }); + }, + ); + + it("identifies Android certificate validation errors without calling them DNS failures", () => { + const message = + "java.security.cert.CertPathValidatorException: Trust anchor for certification path not found."; + expect(failure(message)).toEqual({ + title: "Failed: tcp://example.test:443?ssl=true", + detail: labels.tlsError, + raw: `transport error — ${message}`, + }); + }); + + it.each([ + ["certificate not found", labels.tlsError], + ["SSL handshake failed", labels.tlsError], + ["TLS handshake failed", labels.tlsError], + ["getaddrinfo ENOTFOUND example.test", labels.hostNotFound], + ["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound], + ["Host not found", labels.hostNotFound], + ["connect ECONNREFUSED", labels.refused], + ["connection timed out", labels.timedOut], + ["EHOSTUNREACH", labels.hostUnreachable], + ["unexpected transport failure", labels.unableToConnect], + ])("classifies %s", (message, expected) => { + expect(failure(message).detail).toBe(expected); + }); + + it("preserves authentication failures and their technical details", () => { + const message = "Invalid daemon password"; + expect( + buildConnectionFailureCopy({ + endpoint: "example.test:6767", + error: new DaemonConnectionTestError(message, { + reason: message, + lastError: message, + authFailureReason: "incorrect_password", + }), + labels, + }), + ).toMatchObject({ detail: message, raw: message }); + }); +}); diff --git a/packages/app/src/components/connection-failure-copy.ts b/packages/app/src/components/connection-failure-copy.ts new file mode 100644 index 0000000000..e1ad2f203c --- /dev/null +++ b/packages/app/src/components/connection-failure-copy.ts @@ -0,0 +1,105 @@ +import { + DaemonConnectionTestError, + getConnectionAuthFailureReason, +} from "@/utils/test-daemon-connection"; + +interface ConnectionFailureLabels { + failedToConnect: (endpoint: string) => string; + noAdditionalDetails: (detail: string) => string; + timedOut: string; + refused: string; + hostNotFound: string; + hostUnreachable: string; + tlsError: string; + unableToConnect: string; +} + +function normalizeTransportMessage(message: string | null | undefined): string | null { + if (!message) return null; + const trimmed = message.trim(); + if (!trimmed) return null; + return trimmed; +} + +function formatTechnicalTransportDetails( + details: (string | null)[], + labels: ConnectionFailureLabels, +): string | null { + const unique = Array.from( + new Set( + details + .map((value) => normalizeTransportMessage(value)) + .filter((value): value is string => Boolean(value)) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ); + + if (unique.length === 0) return null; + + const allGeneric = unique.every((value) => { + const lower = value.toLowerCase(); + return lower === "transport error" || lower === "transport closed"; + }); + + if (allGeneric) { + return labels.noAdditionalDetails(unique[0] ?? ""); + } + + return unique.join(" — "); +} + +export function buildConnectionFailureCopy(input: { + endpoint: string; + error: unknown; + labels: ConnectionFailureLabels; +}): { title: string; detail: string | null; raw: string | null } { + const { endpoint, error, labels } = input; + const title = labels.failedToConnect(endpoint); + + const raw = (() => { + if (error instanceof DaemonConnectionTestError) { + return ( + formatTechnicalTransportDetails([error.reason, error.lastError], labels) ?? + normalizeTransportMessage(error.message) + ); + } + if (error instanceof Error) { + return normalizeTransportMessage(error.message); + } + return null; + })(); + + const rawLower = raw?.toLowerCase() ?? ""; + let detail: string | null = null; + + if (getConnectionAuthFailureReason(error)) { + detail = error instanceof Error ? error.message : raw; + } else if (rawLower.includes("timed out")) { + detail = labels.timedOut; + } else if ( + rawLower.includes("econnrefused") || + rawLower.includes("connection refused") || + rawLower.includes("err_connection_refused") + ) { + detail = labels.refused; + } else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) { + detail = labels.hostNotFound; + } else if ( + rawLower.includes("certificate") || + rawLower.includes("certpathvalidator") || + rawLower.includes("trust anchor") || + rawLower.includes("tls") || + rawLower.includes("ssl") + ) { + detail = labels.tlsError; + } else if (rawLower.includes("not found")) { + detail = labels.hostNotFound; + } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { + detail = labels.hostUnreachable; + } else { + detail = labels.unableToConnect; + } + + return { title, detail, raw }; +} diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 11931bdd41..32c739c073 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage"; import type { Theme } from "@/styles/theme"; import { ContextWindowDetails } from "./context-window-details"; import { ContextWindowSheet } from "./context-window-sheet"; +import { formatContextPercentage } from "./context-window-meter.utils"; interface ContextWindowMeterProps { serverId: string; @@ -111,8 +112,9 @@ const ContextWindowRing = withUnistyles(function ContextWindowRing({ width={size} height={size} viewBox={`0 0 ${size} ${size}`} - accessibilityElementsHidden - importantForAccessibility="no-hide-descendants" + {...(isNative + ? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" } + : { "aria-hidden": true })} > {`${context.percentage}%`}
+ + {formatContextPercentage(context.percentage, i18n.resolvedLanguage)} + ) : null; const accessibilityLabel = context ? t("contextWindow.accessibility", { percentage: context.percentage }) diff --git a/packages/app/src/components/context-window-meter.utils.test.ts b/packages/app/src/components/context-window-meter.utils.test.ts new file mode 100644 index 0000000000..394c933176 --- /dev/null +++ b/packages/app/src/components/context-window-meter.utils.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { formatContextPercentage } from "./context-window-meter.utils"; + +describe("context window meter formatting", () => { + it("localizes percentages without hiding context overflow", () => { + expect(formatContextPercentage(125, "en")).toBe("125%"); + expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%"); + }); +}); diff --git a/packages/app/src/components/context-window-meter.utils.ts b/packages/app/src/components/context-window-meter.utils.ts index a0fa45ccbf..055c01386c 100644 --- a/packages/app/src/components/context-window-meter.utils.ts +++ b/packages/app/src/components/context-window-meter.utils.ts @@ -1,3 +1,10 @@ +export function formatContextPercentage(value: number, locale?: string): string { + return new Intl.NumberFormat(locale, { + style: "percent", + maximumFractionDigits: 0, + }).format(value / 100); +} + export function formatTokenCount(value: number): string { if (value >= 1_000_000) { return `${Math.round(value / 1_000_000)}m`; diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts index 87b5e9583f..969bbb581f 100644 --- a/packages/app/src/components/desktop-sidebar-layout.test.ts +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -101,3 +101,25 @@ describe("desktop sidebar layout", () => { ).toBe(true); }); }); + +it("reserves room for Explorer and the workspace before showing app navigation", () => { + const contentMinimumWidth = resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + explorerVisible: true, + }); + expect(contentMinimumWidth).toBe(600); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth, + requestedSidebarWidth: 320, + viewportWidth: 744, + }), + ).toBe(false); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth, + requestedSidebarWidth: 320, + viewportWidth: 951, + }), + ).toBe(true); +}); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts index ff58bc8025..d8518bcb82 100644 --- a/packages/app/src/components/desktop-sidebar-layout.ts +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -1,3 +1,4 @@ +import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout"; import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout"; import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store"; @@ -62,8 +63,12 @@ export function resolveDesktopSidebarWidth(input: { }); } -export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number { - return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0; +export function resolveDesktopAppContentMinimum(input: { + isSettingsRoute: boolean; + explorerVisible?: boolean; +}): number { + if (input.isSettingsRoute) return SETTINGS_DESKTOP_SPLIT_MIN_WIDTH; + return input.explorerVisible ? EXPLORER_WORKSPACE_MIN_WIDTH : 0; } export function canDesktopAppSidebarShare(input: { diff --git a/packages/app/src/components/direct-connection-host.test.ts b/packages/app/src/components/direct-connection-host.test.ts new file mode 100644 index 0000000000..0a2411215a --- /dev/null +++ b/packages/app/src/components/direct-connection-host.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host"; + +const labels = { + hostRequired: "host required", + invalidPort: "invalid port", +}; + +function buildUri(host: string, port = "6767"): string { + return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels); +} + +describe("normalizeDirectConnectionHost", () => { + it.each([ + ["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }], + [" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }], + ["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }], + ["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }], + ["[::1]", { host: "::1", port: null, isIpv6: true }], + ["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }], + ["::1", { host: "::1", port: null, isIpv6: true }], + ["fe80::1", { host: "fe80::1", port: null, isIpv6: true }], + ["", { host: "", port: null, isIpv6: false }], + ])("normalizes %j", (input, expected) => { + expect(normalizeDirectConnectionHost(input)).toEqual(expected); + }); +}); + +describe("buildDirectConnectionUri", () => { + it.each([ + ["100.66.69.36", "tcp://100.66.69.36:6767"], + ["100.66.69.36:6767", "tcp://100.66.69.36:6767"], + ["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"], + ["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"], + ["my-host.local:6767", "tcp://my-host.local:6767"], + ["[::1]", "tcp://[::1]:6767"], + ["[::1]:7000", "tcp://[::1]:7000"], + ["::1", "tcp://[::1]:6767"], + ["fe80::1", "tcp://[fe80::1]:6767"], + ])("builds a tcp URI from host %j", (host, expected) => { + expect(buildUri(host)).toBe(expected); + }); + + it("lets a port typed in the host field override the port field", () => { + expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000"); + }); + + it("keeps TLS and password settings", () => { + expect( + buildDirectConnectionUri( + { host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" }, + labels, + ), + ).toBe("tcp://example.test:443?ssl=true&password=secret"); + }); + + it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => { + expect(() => buildUri(host)).toThrow("host required"); + }); + + it("rejects an invalid port", () => { + expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port"); + expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port"); + }); +}); diff --git a/packages/app/src/components/direct-connection-host.ts b/packages/app/src/components/direct-connection-host.ts new file mode 100644 index 0000000000..db694dd7c2 --- /dev/null +++ b/packages/app/src/components/direct-connection-host.ts @@ -0,0 +1,70 @@ +import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints"; + +export interface DirectConnectionHostInput { + host: string; + port: string; + useTls: boolean; + password: string; +} + +export interface DirectConnectionHostLabels { + hostRequired: string; + invalidPort: string; +} + +export interface NormalizedDirectConnectionHost { + host: string; + /** Port typed into the host field, if any. Overrides the separate port field. */ + port: string | null; + isIpv6: boolean; +} + +const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i; +const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/; +const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/; + +/** + * Users often paste `host:port` or a full URL into the host field. + * Reduce it to a bare host (plus an optional port) before building the URI. + */ +export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost { + let host = input.trim().replace(SCHEME_PATTERN, ""); + const suffixIndex = host.search(/[/?#]/); + if (suffixIndex !== -1) { + host = host.slice(0, suffixIndex); + } + + const bracketed = BRACKETED_IPV6_PATTERN.exec(host); + if (bracketed) { + return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true }; + } + + const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host); + if (hostWithPort) { + return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false }; + } + + return { host, port: null, isIpv6: host.split(":").length > 2 }; +} + +export function buildDirectConnectionUri( + draft: DirectConnectionHostInput, + labels: DirectConnectionHostLabels, +): string { + const normalized = normalizeDirectConnectionHost(draft.host); + const port = Number((normalized.port ?? draft.port).trim()); + if (!normalized.host) { + throw new Error(labels.hostRequired); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(labels.invalidPort); + } + + return serializeConnectionUriForStorage({ + host: normalized.host, + port, + isIpv6: normalized.isIpv6, + useTls: draft.useTls, + ...(draft.password ? { password: draft.password } : {}), + }); +} diff --git a/packages/app/src/components/explorer-sidebar-layout.test.ts b/packages/app/src/components/explorer-sidebar-layout.test.ts index 0570144000..3681ad4e16 100644 --- a/packages/app/src/components/explorer-sidebar-layout.test.ts +++ b/packages/app/src/components/explorer-sidebar-layout.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - explorerSidebarCloseButtonLayout, resolveExplorerSidebarDockSizes, resolveExplorerSidebarWidth, } from "@/components/explorer-sidebar-layout"; @@ -17,24 +16,7 @@ describe("Explorer sidebar layout", () => { it("has no fixed maximum while preserving room for the workspace body", () => { expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240); expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900); - expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800); - expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350); - }); -}); - -describe("Explorer close action", () => { - it("preserves the wide-native touch target and glyph rail", () => { - const layout = explorerSidebarCloseButtonLayout(false); - expect(layout.size).toBe(34); - expect(layout.iconSize).toBe(18); - expect(layout.size + layout.hitSlop * 2).toBe(50); - expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16); - }); - - it("keeps the compact close glyph on the pane rail", () => { - const layout = explorerSidebarCloseButtonLayout(true); - expect(layout.size).toBe(32); - expect(layout.iconSize).toBe(18); - expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8); + expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840); + expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390); }); }); diff --git a/packages/app/src/components/explorer-sidebar-layout.ts b/packages/app/src/components/explorer-sidebar-layout.ts index 5cd5352556..8a39e2e9f4 100644 --- a/packages/app/src/components/explorer-sidebar-layout.ts +++ b/packages/app/src/components/explorer-sidebar-layout.ts @@ -1,36 +1,13 @@ -import { - paneContentToolbarIconSize, - paneContentToolbarTrailingPadding, -} from "@/components/ui/pane-content-toolbar"; -import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome"; - -export function explorerSidebarCloseButtonLayout(compact: boolean) { - // The inline native dock retains its padded close action and touch slop. - if (!compact) { - return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 }; - } - return { - size: smallIconButtonChromeFrameSize(compact), - iconSize: paneContentToolbarIconSize(compact), - hitSlop: 0, - trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"), - }; -} - -/** - * Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts - * the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`). - */ -export const EXPLORER_TAB_RAIL_INSET = 4; - const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320; const MIN_EXPLORER_SIDEBAR_WIDTH = 240; -const MIN_WORKSPACE_BODY_WIDTH = 400; +const MIN_WORKSPACE_BODY_WIDTH = 360; +export const EXPLORER_WORKSPACE_MIN_WIDTH = MIN_EXPLORER_SIDEBAR_WIDTH + MIN_WORKSPACE_BODY_WIDTH; export function resolveExplorerSidebarWidth(input: { requestedWidth?: number; containerWidth: number; }): number { + "worklet"; const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH; const maximumVisibleWidth = input.containerWidth > 0 diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx index b661dbe49a..fa381bf545 100644 --- a/packages/app/src/components/file-drop/file-drop-backdrop.tsx +++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx @@ -34,7 +34,7 @@ export function FileDropBackdrop() { // non-animated children (applying themed Unistyles styles to an Animated.View crashes // on theme change — see docs/unistyles.md). return ( - + @@ -46,7 +46,7 @@ export function FileDropBackdrop() { const positionStyles = RNStyleSheet.create({ overlay: { - ...RNStyleSheet.absoluteFillObject, + ...RNStyleSheet.absoluteFill, alignItems: "center", justifyContent: "center", zIndex: 1000, @@ -55,7 +55,7 @@ const positionStyles = RNStyleSheet.create({ const styles = StyleSheet.create((theme) => ({ backdrop: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, backgroundColor: theme.colors.surface0, opacity: 0.7, }, diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts index 89bbbca7c4..e9b80a00be 100644 --- a/packages/app/src/components/file-drop/types.ts +++ b/packages/app/src/components/file-drop/types.ts @@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem; /** * What a consumer (e.g. a composer) registers to receive files dropped onto the - * surrounding FileDropZone. Raster images arrive already persisted via `onFiles`; - * everything else arrives raw via `onGenericFiles`. + * surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that + * also provides `onFiles` gets its raster images persisted as attachments. */ export interface FileDropSink { - onFiles: (images: ImageAttachment[]) => void; + onFiles?: (images: ImageAttachment[]) => void; onGenericFiles?: (items: DroppedItem[]) => void; onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index 947c95d91e..2e3dc320b4 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -148,8 +148,9 @@ export function useDropListeners({ sink.onGenericFiles(items); } - const imagePaths = payload.paths.filter(isRasterImagePath); - if (imagePaths.length === 0) { + const onFiles = sink.onFiles; + const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : []; + if (!onFiles || imagePaths.length === 0) { return; } @@ -162,7 +163,7 @@ export function useDropListeners({ // composer the user dropped on (matches the web path below). No post-persist busy // re-check: a mixed drop's own generic upload flips the busy flag, and re-checking // would discard the image from the same drop. - sink.onFiles(attachments); + onFiles(attachments); return; }) .catch((error) => { @@ -262,16 +263,17 @@ export function useDropListeners({ sink.onGenericFiles(genericItems); } - const imageFiles = files.filter(isRasterImageFile); + const onFiles = sink.onFiles; + const imageFiles = onFiles ? files.filter(isRasterImageFile) : []; - if (imageFiles.length === 0) return; + if (!onFiles || imageFiles.length === 0) return; try { const attachments = await Promise.all(imageFiles.map(fileToImageAttachment)); // No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag, // and re-checking would discard the image from the same drop. The guard at drop start // already rejects drops that begin while busy. - sink.onFiles(attachments); + onFiles(attachments); } catch (error) { console.error("[useDropListeners] Failed to process dropped files:", error); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 209ec74f28..80bc9b0257 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -77,6 +77,7 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra import { confirmDialog } from "@/utils/confirm-dialog"; import { useToast } from "@/contexts/toast-context"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory"; const SORT_OPTIONS: { value: SortOption }[] = [ @@ -450,9 +451,15 @@ export function FileExplorerPane({ isLocalExecution: isLocalDaemon, }); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); + const projectId = useWorkspaceFields( + serverId, + workspaceId ?? null, + (workspace) => workspace.projectId, + ); const openDirectoryInEditor = useOpenDirectoryInEditor({ serverId, workspaceDirectory: normalizedWorkspaceRoot, + projectId, }); // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. const fsEntryOpsEnabled = useSessionStore( diff --git a/packages/app/src/components/file-pane-render-mode.test.ts b/packages/app/src/components/file-pane-render-mode.test.ts deleted file mode 100644 index 7a0bcf21ec..0000000000 --- a/packages/app/src/components/file-pane-render-mode.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; - -describe("isRenderedMarkdownFile", () => { - it("detects .md files", () => { - expect(isRenderedMarkdownFile("README.md")).toBe(true); - expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true); - }); - - it("detects .markdown files", () => { - expect(isRenderedMarkdownFile("notes.markdown")).toBe(true); - expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true); - }); - - it("does not treat .mdx files as rendered markdown", () => { - expect(isRenderedMarkdownFile("page.mdx")).toBe(false); - }); - - it("does not treat other text files as rendered markdown", () => { - expect(isRenderedMarkdownFile("src/index.ts")).toBe(false); - expect(isRenderedMarkdownFile("README.md.txt")).toBe(false); - expect(isRenderedMarkdownFile("plan.html")).toBe(false); - }); -}); - -describe("filePreviewRenderKind", () => { - it("maps each renderable extension to its kind", () => { - expect(filePreviewRenderKind("README.md")).toBe("markdown"); - expect(filePreviewRenderKind("notes.markdown")).toBe("markdown"); - expect(filePreviewRenderKind("plan.html")).toBe("html"); - expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html"); - expect(filePreviewRenderKind("plan.htm")).toBe("html"); - }); - - it("returns null for files without a rendered preview", () => { - expect(filePreviewRenderKind("src/index.ts")).toBe(null); - expect(filePreviewRenderKind("page.mdx")).toBe(null); - expect(filePreviewRenderKind("index.html.erb")).toBe(null); - }); -}); diff --git a/packages/app/src/components/file-pane-render-mode.ts b/packages/app/src/components/file-pane-render-mode.ts deleted file mode 100644 index b1c36ede46..0000000000 --- a/packages/app/src/components/file-pane-render-mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type FilePreviewRenderKind = "markdown" | "html"; - -export function isRenderedMarkdownFile(filePath: string): boolean { - const normalizedPath = filePath.trim().toLowerCase(); - return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown"); -} - -function isRenderedHtmlFile(filePath: string): boolean { - const normalizedPath = filePath.trim().toLowerCase(); - return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm"); -} - -export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null { - if (isRenderedMarkdownFile(filePath)) return "markdown"; - if (isRenderedHtmlFile(filePath)) return "html"; - return null; -} diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx index 6e4cd525f1..cd59ec4d30 100644 --- a/packages/app/src/components/floating-header/index.tsx +++ b/packages/app/src/components/floating-header/index.tsx @@ -1,8 +1,26 @@ -import { useCallback, useState, type ReactNode } from "react"; +import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react"; import { View, type LayoutChangeEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { Portal, PortalHost } from "@gorhom/portal"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { EdgeFade } from "@/components/ui/edge-fade"; +const OverlayClearanceContext = createContext(0); +const OverlayHostContext = createContext(null); + +/** Keep status overlays above the fade without lifting the scrolling content. */ +export function FloatingHeaderOverlay({ children }: { children: ReactNode }) { + const hostName = useContext(OverlayHostContext); + const active = useRetainedPanelActive(); + if (!hostName) return children; + return active ? {children} : null; +} + +/** Clearance for stationary overlays inside this layout, measured to the header bottom. */ +export function useFloatingHeaderOverlayClearance(): number { + return useContext(OverlayClearanceContext); +} + /** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ const FADE_OVERHANG = 48; /** Share of the fade that stays opaque, so the header's title and icons keep contrast. */ @@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps { * same tree position in both modes, so toggling `floating` never remounts them. */ export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) { + const overlayHostName = useId(); const [headerHeight, setHeaderHeight] = useState(0); const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { setHeaderHeight(Math.round(event.nativeEvent.layout.height)); }, []); + const overlayClearance = floating ? headerHeight : 0; + return ( {floating ? null : header} - {children(floating ? headerHeight : 0)} + + + + {children(floating ? headerHeight : 0)} + + + {floating ? ( - + {header} ) : null} + + + ); } @@ -55,6 +90,13 @@ const styles = StyleSheet.create({ flex: 1, minHeight: 0, }, + statusOverlay: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + }, overlay: { position: "absolute", top: 0, diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index 6ed2bddcbe..cbc161ae66 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -52,8 +52,15 @@ export function CompactHeader({ }: CompactHeaderProps) { const insets = useSafeAreaInsets(); const rootStyle = useMemo( - () => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }], - [insets.top], + () => [ + styles.root, + { + paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, + marginLeft: insets.left, + marginRight: insets.right, + }, + ], + [insets.top, insets.left, insets.right], ); return ( diff --git a/packages/app/src/components/headers/sidebar-close-button.tsx b/packages/app/src/components/headers/sidebar-close-button.tsx new file mode 100644 index 0000000000..3beaa1f7a9 --- /dev/null +++ b/packages/app/src/components/headers/sidebar-close-button.tsx @@ -0,0 +1,59 @@ +import { X } from "lucide-react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +const ThemedX = withUnistyles(X); +const NO_SHORTCUT: ShortcutKey[] = []; + +interface SidebarCloseButtonProps { + label: string; + onPress: () => void; + testID: string; + nativeID?: string; + accessibilityState?: { expanded: boolean }; + style?: StyleProp; +} + +/** + * The X that closes a compact sidebar overlay. It is the compact header's icon button, so the + * sidebar's close reads like the header it covers; every compact sidebar uses it. + */ +export function SidebarCloseButton({ + label, + onPress, + testID, + nativeID, + accessibilityState, + style, +}: SidebarCloseButtonProps) { + return ( + + + + + + ); +} diff --git a/packages/app/src/components/icons/antigravity-icon.tsx b/packages/app/src/components/icons/antigravity-icon.tsx new file mode 100644 index 0000000000..baf2b4c4a6 --- /dev/null +++ b/packages/app/src/components/icons/antigravity-icon.tsx @@ -0,0 +1,17 @@ +import Svg, { Path } from "react-native-svg"; + +interface AntigravityIconProps { + size?: number; + color?: string; +} + +export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/icons/kiro-icon.tsx b/packages/app/src/components/icons/kiro-icon.tsx new file mode 100644 index 0000000000..830e9109aa --- /dev/null +++ b/packages/app/src/components/icons/kiro-icon.tsx @@ -0,0 +1,18 @@ +import Svg, { Path } from "react-native-svg"; + +interface KiroIconProps { + size?: number; + color?: string; +} + +export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/import-session-list.tsx b/packages/app/src/components/import-session-list.tsx new file mode 100644 index 0000000000..d2ff037651 --- /dev/null +++ b/packages/app/src/components/import-session-list.tsx @@ -0,0 +1,4 @@ +import type { ImportSessionListComponent } from "./import-session-list.types"; + +// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified. +export const ImportSessionList: ImportSessionListComponent | null = null; diff --git a/packages/app/src/components/import-session-list.types.ts b/packages/app/src/components/import-session-list.types.ts new file mode 100644 index 0000000000..e62b31bccb --- /dev/null +++ b/packages/app/src/components/import-session-list.types.ts @@ -0,0 +1,13 @@ +import type { ReactElement, ReactNode } from "react"; + +export interface ImportSessionListProps { + data: ReadonlyArray; + keyExtractor: (item: Item) => string; + renderItem: (item: Item) => ReactNode; + header: ReactElement | null; + footer: ReactElement | null; +} + +export type ImportSessionListComponent = ( + props: ImportSessionListProps, +) => ReactElement; diff --git a/packages/app/src/components/import-session-list.web.tsx b/packages/app/src/components/import-session-list.web.tsx new file mode 100644 index 0000000000..aa8f74cd7c --- /dev/null +++ b/packages/app/src/components/import-session-list.web.tsx @@ -0,0 +1,141 @@ +import React, { + useCallback, + useMemo, + useRef, + useState, + type CSSProperties, + type FocusEvent, + type KeyboardEvent, +} from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual"; +import type { ImportSessionListProps } from "./import-session-list.types"; + +const SCROLLER_STYLE: CSSProperties = { + flex: 1, + minHeight: 0, + overflowY: "auto", + overflowX: "hidden", + overscrollBehavior: "contain", +}; +const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" }; + +export function ImportSessionList({ + data, + keyExtractor, + renderItem, + header, + footer, +}: ImportSessionListProps) { + const scrollRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(0); + const [focusedKey, setFocusedKey] = useState(null); + const indexByKey = useMemo( + () => new Map(data.map((item, index) => [keyExtractor(item), index])), + [data, keyExtractor], + ); + const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey); + const rangeExtractor = useCallback( + (range: Range) => { + const visible = defaultRangeExtractor(range); + if (focusedIndex === undefined) return visible; + // Keep focus and its Tab neighbours mounted when PageDown/End moves the window. + const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter( + (index) => index >= 0 && index < data.length, + ); + return [...new Set([...visible, ...focused])].sort((left, right) => left - right); + }, + [data.length, focusedIndex], + ); + const handleFocus = useCallback((event: FocusEvent) => { + const row = event.target.closest("[data-session-key]"); + setFocusedKey(row?.dataset.sessionKey ?? null); + }, []); + const handleBlur = useCallback((event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null); + }, []); + const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { + setHeaderHeight(event.nativeEvent.layout.height); + }, []); + const getScrollElement = useCallback(() => scrollRef.current, []); + const getItemKey = useCallback( + (index: number) => keyExtractor(data[index]!), + [data, keyExtractor], + ); + const estimateSize = useCallback(() => 88, []); + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement, + getItemKey, + estimateSize, + scrollMargin: headerHeight, + overscan: 5, + rangeExtractor, + }); + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const target = event.target; + if ( + event.defaultPrevented || + !(target instanceof HTMLElement) || + !event.currentTarget.contains(target) || + target.isContentEditable || + target.closest("input, textarea, select") + ) + return; + if (event.key === "End") { + event.preventDefault(); + // Reconcile the landing position as variable-height rows are measured. + virtualizer.scrollToIndex(data.length - 1, { align: "end" }); + } else if (event.key === "Home") { + event.preventDefault(); + virtualizer.scrollToOffset(0); + } else if (event.key === "PageDown" || event.key === "PageUp") { + event.preventDefault(); + // Native page-scroll animations can keep moving after Home or End. + const direction = event.key === "PageDown" ? 1 : -1; + virtualizer.scrollBy(direction * event.currentTarget.clientHeight); + } + }, + [data.length, virtualizer], + ); + const virtualRows = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]); + const rowStyle = useCallback( + (offset: number): CSSProperties => ({ + ...CONTENT_STYLE, + position: "absolute", + top: 0, + transform: `translateY(${offset}px)`, + }), + [], + ); + + return ( +
+ {header} +
+ {virtualRows.map((row) => ( +
+ {renderItem(data[row.index]!)} +
+ ))} +
+
{footer}
+
+ ); +} diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 3be15c0892..21296e54e0 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; @@ -11,6 +11,7 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; +import { ImportSessionList } from "@/components/import-session-list"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { useProviderIcon, useProviderIcons } from "@/components/provider-icons"; @@ -371,7 +372,33 @@ function ImportSessionActivityTime({ date }: { date: string }) { return {label}; } +function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string { + return `${entry.providerId}:${entry.providerHandleId}`; +} + +function hasListHeader( + input: SheetStatusMessagesProps & { + showFilter: boolean; + hasProviderErrors: boolean; + }, +): boolean { + return ( + input.showFilter || + !input.isClientReady || + input.isSnapshotUnsupported || + input.hasNoImportableProviders || + (input.isLoadingSessions && !input.hasRows) || + input.importErrored || + input.hasProviderErrors + ); +} + function SessionRows({ + visible, + onClose, + header, + listHeader, + listFooter, serverId, entries, disabled, @@ -379,6 +406,11 @@ function SessionRows({ resolveFolder, onImportSession, }: { + visible: boolean; + onClose: () => void; + header: SheetHeader; + listHeader: ReactNode; + listFooter: ReactNode; serverId: string | null; entries: ReadonlyArray; disabled: boolean; @@ -386,20 +418,69 @@ function SessionRows({ resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null; onImportSession: (entry: FetchRecentProviderSessionEntry) => void; }) { + // Keep short results content-sized; only long lists need a bounded viewport. + const virtualized = ImportSessionList !== null && entries.length > 8; + const renderSessionRow = useCallback( + (entry: FetchRecentProviderSessionEntry) => ( + + ), + [serverId, disabled, importingSessionKey, resolveFolder, onImportSession], + ); + const renderItem = useCallback( + (item: FetchRecentProviderSessionEntry) => ( + {renderSessionRow(item)} + ), + [renderSessionRow], + ); + const virtualHeader = useMemo( + () => (listHeader ? {listHeader} : null), + [listHeader], + ); + const virtualFooter = useMemo( + () => {listFooter}, + [listFooter], + ); + return ( - - {entries.map((entry) => ( - - ))} - + + {ImportSessionList && virtualized ? ( + + + + ) : ( + <> + {listHeader} + {entries.length > 0 ? ( + {entries.map(renderSessionRow)} + ) : null} + {listFooter} + + )} + ); } @@ -726,85 +807,124 @@ export function ImportSessionSheet({ }); const showFilter = filterProviders.length > 1; const showLoadMore = hasMoreSessions(queries, pageLimit); - + const showListHeader = hasListHeader({ + showFilter, + isClientReady: Boolean(client), + isSnapshotUnsupported, + hasNoImportableProviders, + isLoadingSessions, + hasRows: visibleEntries.length > 0, + importErrored: importMutation.isError, + hasProviderErrors: providerErrorRows.length > 0, + }); + const listHeader = useMemo( + () => + showListHeader ? ( + + {showFilter ? ( + + + {selectedProvider === ALL_FILTER_VALUE ? ( + + ) : ( + + )} + + {selectedProviderLabel} + + + + + + ) : null} + 0} + importErrored={importMutation.isError} + /> + {providerErrorRows.length > 0 ? ( + + ) : null} + + ) : null, + [ + showListHeader, + showFilter, + filterTriggerStyle, + handleFilterOpen, + selectedProvider, + theme.colors.foregroundMuted, + SelectedProviderIcon, + selectedProviderLabel, + filterComboboxOptions, + handleFilterSelect, + renderFilterOption, + isFilterOpen, + client, + isSnapshotUnsupported, + hasNoImportableProviders, + isLoadingSessions, + visibleEntries.length, + importMutation.isError, + providerErrorRows, + handleRetryProvider, + ], + ); + const listFooter = useMemo( + () => ( + <> + {showLoadMore ? ( + + + + ) : null} + {showEmptyState ? : null} + + ), + [showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle], + ); return ( - - {showFilter ? ( - - - {selectedProvider === ALL_FILTER_VALUE ? ( - - ) : ( - - )} - - {selectedProviderLabel} - - - - - - ) : null} - 0} - importErrored={importMutation.isError} - /> - {providerErrorRows.length > 0 ? ( - - ) : null} - {visibleEntries.length > 0 ? ( - - ) : null} - {showLoadMore ? ( - - - - ) : null} - {showEmptyState ? : null} - + listHeader={listHeader} + listFooter={listFooter} + serverId={serverId} + entries={visibleEntries} + disabled={importMutation.isPending} + importingSessionKey={importingSessionKey} + resolveFolder={resolveFolder} + onImportSession={handleImportSession} + /> ); } @@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, + listBody: { + gap: 0, + }, list: { + flex: 1, + minHeight: 0, + marginHorizontal: -theme.spacing[2], + }, + listHeader: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + listHeaderContent: { + gap: theme.spacing[4], + }, + smallList: { gap: theme.spacing[1], }, + listFooter: { + marginHorizontal: theme.spacing[2], + }, + listRow: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, footer: { paddingTop: theme.spacing[2], alignItems: "center", diff --git a/packages/app/src/components/indent-rail.tsx b/packages/app/src/components/indent-rail.tsx new file mode 100644 index 0000000000..36fd84a0a9 --- /dev/null +++ b/packages/app/src/components/indent-rail.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * Align the rail with the expanded caret's left edge. Keep the content offset fixed so + * expanded tool cards stay in place, with a gap between their border and the rail. + */ +const RAIL_LEFT = 1; +const RAIL_WIDTH = 1; +export const INDENT_RAIL_CONTENT_OFFSET = 21; + +/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */ +export function IndentRail({ + children, + style, +}: { + children?: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + rail: { + marginLeft: RAIL_LEFT, + paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH, + borderLeftWidth: RAIL_WIDTH, + borderLeftColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 943c1edd27..fff2e84f45 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,3 +1,4 @@ +import { useAddHost } from "@/hosts/add-host"; import { router } from "expo-router"; import { CircleGauge, @@ -5,7 +6,6 @@ import { GitBranch, Server, Settings, - X, type LucideIcon, } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -23,6 +23,7 @@ import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, @@ -62,14 +63,9 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; +import { buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; -import { - UsageSidebarItem, - UsageSidebarRoot, - useHasUsageSummary, - useOpenSidebarUsage, -} from "@/usage"; +import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -166,9 +162,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const handleOpenProjectMobile = useCallback(() => { - showMobileAgent(); void openProjectPicker(); - }, [showMobileAgent, openProjectPicker]); + }, [openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { void openProjectPicker(); @@ -183,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole router.push(buildSettingsRoute()); }, []); - const handleAddHostMobile = useCallback(() => { - showMobileAgent(); - router.push(buildSettingsAddHostRoute(Date.now())); - }, [showMobileAgent]); - - const handleAddHostDesktop = useCallback(() => { - router.push(buildSettingsAddHostRoute(Date.now())); - }, []); + const handleAddHostMobile = useAddHost(); + const handleAddHostDesktop = handleAddHostMobile; const handleOpenHostSettingsMobile = useCallback( (serverId: string) => { @@ -205,9 +194,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole }, []); const handleImportSessionMobile = useCallback(() => { - showMobileAgent(); openImportSession(); - }, [openImportSession, showMobileAgent]); + }, [openImportSession]); const labels = useMemo( (): SidebarLabels => ({ @@ -482,10 +470,10 @@ function SidebarUsageIcon({ label }: { label: string }) { */ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { const { items } = useSidebarNavItems("footer"); - const hasUsageSummary = useHasUsageSummary(); + const usageSummary = useUsageSummary(); const rowsRef = useRef(null); const visibleItems = items.filter( - (item) => item.visible && (item.kind === "plugin" || hasUsageSummary), + (item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0), ); if (visibleItems.length === 0) return null; return ( @@ -501,7 +489,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void onBeforeNavigate={onBeforeNavigate} /> ) : ( - + ), )} @@ -545,6 +533,19 @@ function MobileSidebar({ closeSidebar(); }, [closeSidebar]); + const closeButton = useMemo( + () => ( + + ), + [labels.closeSidebar, closeSidebar], + ); + const mobileSidebarInsetStyle = useMemo( () => ({ paddingTop: insetsTop, @@ -562,30 +563,11 @@ function MobileSidebar({ > - - - - {({ hovered, pressed }) => ( - - )} - - + {isInitialLoad && !hasActiveHostFilter ? ( @@ -656,7 +638,9 @@ function DesktopSidebar({ const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); - const { width: viewportWidth } = useWindowDimensions(); + const { width: windowWidth } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const viewportWidth = windowWidth - insets.left - insets.right; const visibleSidebarWidth = resolveDesktopSidebarWidth({ requestedWidth: sidebarWidth, viewportWidth, @@ -725,8 +709,11 @@ function DesktopSidebar({ [active, resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( - () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], - [insetsTop], + () => [ + styles.desktopSidebarBorder, + { flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom }, + ], + [insetsTop, insets.bottom], ); const sidebarHeaderGroupStyle = useMemo( () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], @@ -856,24 +843,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - mobileCloseButtonRow: { - position: "absolute", - top: theme.spacing[3], - left: 0, - right: 0, - zIndex: 2, - alignItems: "flex-end", - }, mobileCloseButton: { - // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. - // This optical inset puts their painted right edges on the same sidebar rail. - marginRight: theme.spacing[2] + 1.5, - width: 32, - height: 32, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surfaceSidebar, + marginRight: theme.spacing[2], }, desktopSidebarBorder: { borderRightWidth: 1, diff --git a/packages/app/src/components/markdown/block-stack.tsx b/packages/app/src/components/markdown/block-stack.tsx new file mode 100644 index 0000000000..e17421a84c --- /dev/null +++ b/packages/app/src/components/markdown/block-stack.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup"; +import { isWeb } from "@/constants/platform"; +import { density } from "@/styles/density"; + +/** Stacks Markdown blocks rendered as separate views with the same gap the text puts between its blocks. */ +export function MarkdownBlockStack({ + imageSources, + children, +}: { + imageSources?: Record; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + stack: { gap: density.blockGap }, +}); diff --git a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx index 8b68ae61cb..02ca7108d8 100644 --- a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx @@ -1,5 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Modal, View } from "react-native"; +import { View } from "react-native"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; diff --git a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx index 001ec5cf1c..5f86efe05c 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx @@ -1,6 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - Modal, Pressable, StyleSheet as RNStyleSheet, ScrollView, diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx index fed9afe3a3..9370bc90c0 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from "react"; -import { Pressable, View, type TextStyle, type ViewStyle } from "react-native"; +import { Pressable, View, type ViewStyle } from "react-native"; import { Code, Maximize2, Workflow } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -68,15 +68,6 @@ function MermaidFenceHostImpl({ ], [openFullscreen, showSourcePress, t], ); - const sourceView = useMemo(() => { - const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle; - const margins: ViewStyle = { - marginTop: marginTop ?? marginVertical, - marginBottom: marginBottom ?? marginVertical, - }; - const text: TextStyle = sourceTextStyle; - return { container: [margins, sourceContainerStyle], text }; - }, [textStyle]); const diagramStyle = useMemo( () => [ getDiagramBoxStyle(textStyle), @@ -90,19 +81,17 @@ function MermaidFenceHostImpl({ [visible], ); const sourceVisible = !diagramVisible; - const sourceContainer = showSource ? sourceView.container : sourceContainerStyle; - const sourceTextStyle = showSource ? sourceView.text : textStyle; const viewportStyle = diagramVisible ? diagramStyle : measuringStyle; return ( <> {sourceVisible ? ( - + {showSource && canShowDiagram ? ( ; - children: ReactElement; -}) { - return ( - - {children} - - ); -} diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index 83b24b481b..e2f8d07992 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -35,11 +35,15 @@ export interface MarkdownImageContext { filePath?: string; } -export function useMarkdownSource(text: string, context: MarkdownImageContext) { +export function useMarkdownSource( + text: string, + context: MarkdownImageContext, + commandNames?: ReadonlySet, +) { const occurrenceKey = useId(); const prepared = useMemo( - () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }), - [text, context.workspaceRoot], + () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }), + [text, context.workspaceRoot, commandNames], ); const sources = [...new Set(prepared.images.map((image) => image.source))]; const acquisitions = sources.map((source) => { diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 218cdc8884..1722cd4343 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,12 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; +import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; +import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; +import { matchCommandLink } from "@/agent-commands"; + +import { classifyAssistantFileLink } from "@/assistant-file-links"; +import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; +import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding"; import { describe, expect, it } from "vitest"; @@ -8,6 +15,28 @@ import { createMarkdownStyles } from "./styles"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { normalizeMarkdownHtml } from "./html"; +it("recognizes complete known commands in inline code", () => { + const names = new Set(["code-review", "review:fast"]); + const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, { + commandNames: names, + }); + expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true); + expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe( + prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"), + ); + const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names }); + expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true); + expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review"); +}); + +it("resolves authored root-relative links only when the target is a known command", () => { + const names = new Set(["code-review"]); + expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review"); + expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull(); + expect(matchCommandLink("/code-review", "text", names)).toBeNull(); + expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull(); +}); + describe("shared Markdown source adaptation", () => { it("keeps paragraphs, fences and quotes together for the library", () => { const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; @@ -31,6 +60,56 @@ describe("shared Markdown source adaptation", () => { "> - ![Image]()\n\n[picture]: ../a.png", ); }); + it("gives an image alone on its line its own paragraph", () => { + expect( + prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(), + ).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it."); + expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe( + "![A](https://x/a.png)\n\n![B](https://x/b.png)", + ); + expect( + prepareMarkdownSource( + "Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three", + ).render(), + ).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three"); + }); + it("keeps images that share a line with text inline", () => { + const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line"; + expect(prepareMarkdownSource(source).render()).toBe(source); + }); + it("splits own-line images inside quotes and list items without ending the container", () => { + expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe( + "> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More", + ); + expect( + prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(), + ).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next"); + expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe( + "- ![Shot](https://x/a.png)\n\n Caption line", + ); + }); + it("renders own-line images as blocks through the library parser", async () => { + const source = + "Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)"; + const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { + hardSoftBreaks: true, + latexMath: false, + }); + markInlineImages(tree); + const images: ASTNode[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Image") images.push(node); + node.children?.forEach(collect); + }; + collect(tree); + expect( + images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), + ).toEqual([ + ["https://x/a.png", false], + ["https://x/i.png", true], + ["https://x/q.png", false], + ]); + }); it("escapes spoiler delimiters only in prose", () => { expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe( "Show \\|\\|words\\|\\| and `||code||`.", @@ -51,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => { ); }); +it("renders a table directly after a paragraph through the library parser", async () => { + const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |"; + const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |"; + const flags = { hardSoftBreaks: true, latexMath: false }; + const expected = await parseMarkdown(separated, flags); + expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]); + expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected); +}); + +it.each([ + [ + "a quote", + "> Notes\n> | Item |\n> | --- |\n> | A |", + "> Notes\n>\n> | Item |\n> | --- |\n> | A |", + ["Document", ["Blockquote", "Paragraph", "Table"]], + ], + [ + "a list item", + "- Notes\n | Item |\n | --- |\n | A |\n- Next", + "- Notes\n\n | Item |\n | --- |\n | A |\n- Next", + ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], + ], + [ + "a quoted list item", + "> - Notes\n> | Item |\n> | --- |\n> | A |", + "> - Notes\n>\n> | Item |\n> | --- |\n> | A |", + ["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]], + ], + [ + "a quote inside a list item", + "- > Notes\n > | Item |\n > | --- |\n > | A |", + "- > Notes\n >\n > | Item |\n > | --- |\n > | A |", + ["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]], + ], + [ + "aligned columns without outer pipes", + "Notes\nItem | Detail\n:--- | ---:\nA | one", + "Notes\n\nItem | Detail\n:--- | ---:\nA | one", + ["Document", "Paragraph", "Table"], + ], + [ + "an entity at the start of a header without outer pipes", + "Notes\nAT&T | Detail\n--- | ---\nA | one", + "Notes\n\nAT\\&T | Detail\n--- | ---\nA | one", + ["Document", "Paragraph", "Table"], + ], + [ + "a local image at the start of a header without outer pipes", + "Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one", + "Notes\n\nLocal | Detail\n--- | ---\nA | one", + ["Document", "Paragraph", "Table"], + ], + [ + "CRLF lines", + "Notes\r\n| Item |\r\n| --- |\r\n| A |", + "Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |", + ["Document", "Paragraph", "Table"], + ], + [ + "CR lines", + "Notes\r| Item |\r| --- |\r| A |", + "Notes\r\r| Item |\r| --- |\r| A |", + ["Document", "Paragraph", "Table"], + ], + [ + "a quote with CRLF lines", + "> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |", + "> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |", + ["Document", ["Blockquote", "Paragraph", "Table"]], + ], + [ + "a list item with CRLF lines", + "- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next", + "- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next", + ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], + ], + [ + "a quote with CR lines", + "> Notes\r> | Item |\r> | --- |\r> | A |", + "> Notes\r>\r> | Item |\r> | --- |\r> | A |", + ["Document", ["Blockquote", "Paragraph", "Table"]], + ], + [ + "a list item with CR lines", + "- Notes\r | Item |\r | --- |\r | A |\r- Next", + "- Notes\r\r | Item |\r | --- |\r | A |\r- Next", + ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], + ], + [ + "a non-interrupting ordered marker with tildes", + "Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |", + "Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |", + ["Document", "Paragraph", "Table"], + ], + [ + "a non-interrupting ordered marker with backticks", + "Notes\n2. ```\n | Item |\n | --- |\n | A |", + "Notes\n2. ```\n\n | Item |\n | --- |\n | A |", + ["Document", "Paragraph", "Table"], + ], +])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => { + const rendered = prepareMarkdownSource(source).render(); + expect(rendered).toEqual(separated); + const flags = { hardSoftBreaks: true, latexMath: false }; + function blocks(node: ASTNode): unknown { + if (node.type === "Paragraph" || node.type === "Table") return node.type; + return [node.type, ...(node.children ?? []).map(blocks)]; + } + expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected); +}); + +it.each([ + [ + "a literal backslash", + "Notes\\\n| Item |\n| --- |\n| A |", + "Notes\\\n\n| Item |\n| --- |\n| A |", + "Notes\\", + ], + [ + "an entity and literal backslash with LF lines", + "AT&T\\\n| Item |\n| --- |\n| A |", + "AT\\&T\\\n\n| Item |\n| --- |\n| A |", + "AT&T\\", + ], + [ + "an entity and literal backslash with CRLF lines", + "AT&T\\\r\n| Item |\r\n| --- |\r\n| A |", + "AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |", + "AT&T\\", + ], + [ + "an entity and literal backslash with CR lines", + "AT&T\\\r| Item |\r| --- |\r| A |", + "AT\\&T\\\r\r| Item |\r| --- |\r| A |", + "AT&T\\", + ], +])( + "preserves paragraph text before an interrupting table in %s", + async (_name, source, separated, text) => { + const rendered = prepareMarkdownSource(source).render(); + expect(rendered).toEqual(separated); + const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false }); + expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]); + expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text); + }, +); + +it.each([ + "```yaml\n---\nkey: value\n---\n```\n", + "~~~md\n| Item |\n| --- |\n| A |\n~~~\n", + "````md\n```\n| Item |\n| --- |\n```\n`````\n", + "> ```md\n> | Item |\n> | --- |\n> ```\n", + "> ```md\n> Code\n", + "- ```md\n Code\n", + " ```\n\n", + "```not`a-fence\n", +])("renders a table after a fence without changing its code: %s", async (prefix) => { + const table = "| Item |\n| --- |\n| A |"; + const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render(); + expect(rendered).toEqual(`${prefix}Notes\n\n${table}`); + const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false }); + expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]); +}); + +it.each([ + "| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100), + "Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100), + "Notes\n\n| Item |\n| --- |\n| A |", + "| Item |\n| --- |\n| A |", + "```md\nNotes\n| Item |\n| --- |\n| A |\n```", + "```yaml\n---\nkey: value\n---\n```", + "```yaml\n---\nkey: value", + "~~~md\n| Item |\n| --- |\n| A |\n~~~", + "````md\n```\n| Item |\n| --- |\n```\n````", + " Notes\n | Item |\n | --- |\n | A |", + "Notes\n| Item | Detail |\n| --- |\n| A |", +])("leaves separated tables and table-like text unchanged: %s", (source) => { + expect(prepareMarkdownSource(source).render()).toEqual(source); +}); + it.each([ "src/components/message.tsx", "C:\\repo\\file.ts", @@ -59,7 +318,7 @@ it.each([ "src/file.ts:10-20", "src/file.ts(10,2)", ])("recognizes inline paths through the file resolver: %s", (path) => { - const prepared = prepareMarkdownSource(`Open \`${path}\`.`); + const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo"); expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true); expect(prepared.linkSource(path, "inline-code")).toEqual({ href: path, @@ -68,6 +327,185 @@ it.each([ }); }); +it("preserves stable inline-code file classification through the library parser", async () => { + const paths = [ + "src/app/[id].tsx", + "src/app/(tabs)/index.tsx", + "src/a$b.ts", + ".gitignore", + "src/index.ts#L10-L20", + "src/index.ts:10-20", + "src/index.ts:20-10", + "src/index.ts#L0", + "src/index.ts?raw", + "before/after", + "word.word", + "README.md", + "./scripts/run", + "src/file.ts(10,2)", + "src/file.ts lines 10-20", + "/tmp/[route].tsx", + "file:///tmp/file.ts#L10-L20", + ]; + const source = paths.map((path) => `\`${path}\``).join(" and "); + const prepared = prepareMarkdownSource(source, "/repo"); + const tree = recognizeTextLinks( + await parseMarkdown(prepared.render(), { latexMath: false }), + prepared.linkRecognition.text, + prepared.linkRecognition.inlineCode, + ); + const links: string[] = []; + function collect(node: ASTNode) { + if (node.type === "Link") links.push(String(node.attributes?.url)); + node.children?.forEach(collect); + } + collect(tree); + expect(links).toEqual([ + "src/app/[id].tsx", + "src/app/(tabs)/index.tsx", + "src/a$b.ts", + ".gitignore", + "src/index.ts#L10-L20", + "src/index.ts:10-20", + "README.md", + "./scripts/run", + "src/file.ts(10,2)", + "/tmp/[route].tsx", + "file:///tmp/file.ts#L10-L20", + ]); + expect( + classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }), + ).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } }); +}); + +it("lets the engine own code boundaries while classifying candidate values", async () => { + const source = [ + "```ts\n`src/fenced.ts`\n```", + "`` src/app/[id].tsx `` and ` src/spaced.ts `", + "``src/a`b.ts`` and `src/after.ts`", + "\\`src/escaped.ts` and `src/final.ts`", + "`\nsrc/newline.ts\n`", + "[`src/explicit.ts`](https://example.com)", + ].join("\n\n"); + const prepared = prepareMarkdownSource(source, "/repo"); + const tree = recognizeTextLinks( + await parseMarkdown(prepared.render(), { latexMath: false }), + prepared.linkRecognition.text, + prepared.linkRecognition.inlineCode, + ); + const recognized: string[] = []; + function collect(node: ASTNode) { + if (node.type === "Link" && node.attributes?.recognizedLink === "true") { + recognized.push(String(node.attributes.url)); + } + node.children?.forEach(collect); + } + collect(tree); + expect(recognized).toEqual([ + "src/app/[id].tsx", + " src/spaced.ts ", + "src/a`b.ts", + "src/after.ts", + "src/escaped.ts", + "src/final.ts", + "src/newline.ts", + ]); +}); + +describe("bare URL sentence punctuation", () => { + it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])( + "leaves %s outside the link and preserves the rendered sentence", + async (punctuation) => { + const url = "https://github.com/getpaseo/paseo/pull/7357"; + const source = `${url}${punctuation} Not merged.`; + const prepared = prepareMarkdownSource(source); + const tree = recognizeTextLinks( + await parseMarkdown(prepared.render(), { latexMath: false }), + prepared.linkRecognition.text, + prepared.linkRecognition.inlineCode, + ); + expect(tree.children?.[0]?.children).toEqual([ + { + type: "Link", + attributes: { url }, + children: [{ type: "Text", content: url }], + }, + { type: "Text", content: `${punctuation} Not merged.` }, + ]); + }, + ); + + it.each([ + "https://example.com/a.b", + "https://example.com/a?foo=bar&baz=2", + "https://example.com/a#L10", + "https://en.wikipedia.org/wiki/Function_(mathematics)", + "https://example.com/a_(b_(c))", + ])("preserves internal URL punctuation: %s", async (url) => { + const tree = await parseMarkdown(prepareMarkdownSource(`${url}.`).render()); + expect(tree.children?.[0]?.children).toEqual([ + { + type: "Link", + attributes: { url }, + children: [{ type: "Text", content: url }], + }, + { type: "Text", content: "." }, + ]); + }); + + it.each([ + ["http://127.0.0.1:8059/?token=t-1", ""], + ["http://example.com:8080/a", "."], + ["http://localhost:8059/?token=t-1", "."], + ["http://localhost:8059/?token=t-1", ")"], + ["http://user@127.0.0.1:8059/?token=t-1", '"'], + ["http://user:pass@127.0.0.1:8059/?token=t-1", ""], + ["http://localhost", ":"], + ["http://[::1]:8059/?token=t-1", "'"], + ["https://github.com/o/r/compare/main...user:repo:fix/branch", ""], + ["https://example.com/a:b/c", ":"], + ])("links %s whole and leaves %j outside", async (url, punctuation) => { + const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`); + const tree = recognizeTextLinks( + await parseMarkdown(prepared.render(), { latexMath: false }), + prepared.linkRecognition.text, + prepared.linkRecognition.inlineCode, + ); + expect(tree.children?.[0]?.children).toEqual([ + { type: "Text", content: "See " }, + { + type: "Link", + attributes: expect.objectContaining({ url }), + children: [{ type: "Text", content: url }], + }, + { type: "Text", content: `${punctuation} now` }, + ]); + }); + + it.each(["[Authored](https://example.com/a.)", ""])( + "preserves punctuation in explicit destinations: %s", + async (source) => { + const tree = await parseMarkdown(prepareMarkdownSource(source).render()); + expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a."); + }, + ); +}); + +it("requires workspace context for relative inline paths and escapes literal regex characters", () => { + const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`"; + const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode; + expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false); + expect(withoutWorkspace.test("README.md")).toBe(false); + expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true); + const prepared = prepareMarkdownSource(source, "/repo"); + const native = toNativeLinkRecognition(prepared.linkRecognition); + const pattern = new RegExp(native.inlineCode.pattern); + expect(pattern.test("src/app/[id].tsx")).toBe(true); + expect(pattern.test("src/a+b.ts")).toBe(true); + expect(pattern.test("src/app/i.tsx")).toBe(false); + expect(pattern.test("src/ab.ts")).toBe(false); +}); + it("recognizes bare Unicode filenames and preserves inline-code URLs", () => { const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`."); expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([ @@ -152,7 +590,7 @@ describe("shared Markdown theme colours", () => { ["dark", darkTheme], ["light", lightTheme], ] as const)("uses %s theme colours for tables and task labels", (_name, theme) => { - const { markdownStyle: style } = createMarkdownStyles(theme); + const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 }); expect(style.table).toMatchObject({ color: theme.colors.foreground, headerTextColor: theme.colors.foreground, @@ -182,7 +620,7 @@ it.each([ ] as const)( "uses %s theme colours for quotes, admonitions, code, links and dividers", (_name, theme) => { - const { markdownStyle: style } = createMarkdownStyles(theme); + const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 }); expect(style.blockquote).toMatchObject({ color: `${theme.colors.foreground}cc`, backgroundColor: theme.colors.surface1, @@ -209,7 +647,7 @@ it.each([ ); it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => { - expect(createMarkdownStyles(theme)).toHaveProperty( + expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty( "selectionColor", theme.colors.terminal.selectionBackground, ); @@ -276,7 +714,7 @@ it("preserves backslashes and decodes entities inside HTML code", () => { it.each([false, true])( "caps inline images to the paragraph line height (compact: %s)", (compact) => { - const { markdownStyle } = createMarkdownStyles(darkTheme, compact); + const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 }); expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight); }, ); @@ -436,7 +874,7 @@ it.each([ "×/÷", "2024/10/16", ])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => { - const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`); + const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo"); expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]); expect(linkRecognition.inlineCode.test(token)).toBe(false); }); @@ -461,12 +899,11 @@ it.each([ "123.ts", "FILE.TS", "https://example.com/path", -])("recognizes complete paths and URLs in prose and inline code: %s", (token) => { - const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`); +])("recognizes complete paths and URLs in prose: %s", (token) => { + const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo"); expect( [...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text), ).toEqual([token]); - expect(linkRecognition.inlineCode.test(token)).toBe(true); }); it.each([ @@ -477,10 +914,13 @@ it.each([ "packages/app", "123/file", "src/123", -])("requires stronger path intent in prose while preserving inline code: %s", (token) => { - const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`); +])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => { + const { linkRecognition } = prepareMarkdownSource( + `Compare ${token}, then \`${token}\`.`, + "/repo", + ); expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]); - expect(linkRecognition.inlineCode.test(token)).toBe(true); + expect(linkRecognition.inlineCode.test(token)).toBe(false); }); it("keeps prose and inline-code recognition enabled through the native regex adapter", () => { @@ -561,3 +1001,25 @@ it("replaces the native placeholder only after the resolved URL decodes", () => const prepared = prepareMarkdownSource("![image](shot.png)", undefined, { supportsSvg: false }); expect(prepared.render(new Map([["shot.png", url]]))).toBe(`![image](<${url}>)`); }); + +it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => { + expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({ + userSelect: "text", + WebkitUserSelect: "text", + }); +}); + +it.each([false, true])( + "scales inline code from theme tokens with its surrounding block (compact: %s)", + (compact) => { + const theme = { + ...darkTheme, + fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 }, + }; + const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 }); + const bodySize = compact ? theme.fontSize.base : theme.fontSize.content; + expect(markdownStyle.code).not.toHaveProperty("fontSize"); + expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize); + expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code); + }, +); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index c4a832342f..c14ecb5198 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -6,10 +6,12 @@ import { useAssistantFileLinkEnvironment, } from "@/assistant-file-links"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import type { Theme } from "@/styles/theme"; import { isWeb } from "@/constants/platform"; -import { MarkdownRendererFrame } from "./frame"; +import { density, resolveDensity } from "@/styles/density"; +import { MarkdownBlockStack } from "./block-stack"; import { WEB_RENDERERS } from "./renderers"; import { createMarkdownStyles } from "./styles"; import { PENDING_MARKDOWN_IMAGE } from "./source"; @@ -21,15 +23,33 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen const ThemedMarkdown = withUnistyles(EnrichedMarkdownText); const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }; -const regularStyle = (theme: Theme) => createMarkdownStyles(theme); -const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true); -const WEB_CONTAINER = { userSelect: "text" as const }; +// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it. +const markdownStyle = + (compact: boolean, muted = false) => + (theme: Theme, rt: { breakpoint?: string }) => + createMarkdownStyles(theme, { + compact, + muted, + blockGap: resolveDensity(rt, density.blockGap), + }); +const regularStyle = markdownStyle(false); +const compactStyle = markdownStyle(true); +const compactMutedStyle = markdownStyle(true, true); + +function pickMarkdownStyle(compact: boolean, muted: boolean) { + if (!compact) return regularStyle; + return muted ? compactMutedStyle : compactStyle; +} export interface MarkdownRendererProps extends MarkdownImageContext { text: string; compact?: boolean; + /** Muted text colour. Compact surfaces only. */ + muted?: boolean; + selectable?: boolean; streaming?: boolean; onLinkPress?: (url: string) => unknown; + agentCommands?: boolean; } /** All Markdown surfaces share the same engine and source adaptation. */ @@ -41,23 +61,32 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe () => splitStreamingMarkdown(completeLines, true).head.length, [completeLines], ); - if (!streaming || boundary === 0) return ; + const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart; + if (!streaming || boundary === 0) return ; return ( - <> - - - + + + + ); }); +const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) { + const commandHints = useAgentCommandHints(); + return ; +}); + const MarkdownPart = memo(function MarkdownPart({ text, - continues = false, compact = false, + muted = false, + selectable = true, streaming = false, onLinkPress, + agentCommands: _agentCommands, + commandHints = null, ...imageContext -}: MarkdownRendererProps & { continues?: boolean }) { +}: MarkdownRendererProps & { commandHints?: CommandHints | null }) { const environment = useAssistantFileLinkEnvironment(); const actions = useAssistantFileLinkActions(); const client = @@ -65,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({ (environment.client?.readFile ? { readFile: environment.client.readFile.bind(environment.client) } : undefined); - const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, { - workspaceRoot: environment.workspaceRoot, - serverId: environment.serverId, - ...imageContext, - client, - }); + const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource( + text, + { + workspaceRoot: environment.workspaceRoot, + serverId: environment.serverId, + ...imageContext, + client, + }, + commandHints?.names, + ); const [image, setImage] = useState(null); const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => { + const command = commandHints && matchCommandLink(url, sourceType, commandHints.names); + if (command) { + commandHints.insert(command); + return; + } const source = linkSource(url, sourceType); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); else actions.open(source, "preferred"); @@ -86,35 +124,34 @@ const MarkdownPart = memo(function MarkdownPart({ isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown), [markdown], ); - const content = ( + return ( - {segments.map((segment) => - segment.kind === "mermaid" ? ( - - ) : ( - - ), - )} + + {segments.map((segment) => + segment.kind === "mermaid" ? ( + + ) : ( + + ), + )} + {image ? : null} ); - return {content}; }); diff --git a/packages/app/src/components/markdown/renderers.web.tsx b/packages/app/src/components/markdown/renderers.web.tsx index 6f0afc666b..1d05b3b603 100644 --- a/packages/app/src/components/markdown/renderers.web.tsx +++ b/packages/app/src/components/markdown/renderers.web.tsx @@ -1,5 +1,5 @@ import { Text } from "react-native"; -import { forwardRef, useMemo, type ComponentProps } from "react"; +import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react"; import type { CodeBlockRendererProps, LinkRendererProps, @@ -27,14 +27,29 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) { return ; } +// The library styles links inline, so a stylesheet :hover rule cannot underline them. +const LINK_HOVER_STYLE = { textDecoration: "underline" } as const; + function WebLink({ link, anchorProps, children }: LinkRendererProps) { const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType)); + const [hovered, setHovered] = useState(false); + const handleMouseEnter = useCallback(() => setHovered(true), []); + const handleMouseLeave = useCallback(() => setHovered(false), []); const anchorStyle = useMemo( - () => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }), - [anchorProps.style], + () => ({ + ...anchorProps.style, + overflowWrap: "anywhere" as const, + ...(hovered ? LINK_HOVER_STYLE : null), + }), + [anchorProps.style, hovered], ); const anchor = ( - + {children} ); diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index fc6f7463a5..2ca3ebc474 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -1,3 +1,4 @@ +import { classifyAssistantFileLink } from "@/assistant-file-links"; import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions"; import { normalizeMarkdownHtml } from "./html"; import { gfmTable } from "micromark-extension-gfm-table"; @@ -5,7 +6,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import type { AssistantFileLinkSource } from "@/assistant-file-links"; import { decodeString } from "micromark-util-decode-string"; import { fromMarkdown } from "mdast-util-from-markdown"; -import type { Link, Nodes, Root } from "mdast"; +import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. @@ -22,31 +23,70 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`; const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`; // Bare prose paths need a known extension, a line suffix, or at least two separators. const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`; -const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`; function createFilePattern(path: string): RegExp { // The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape. return new RegExp( - String.raw`(?:https?://[^\s<>]+|(?()"'\x60]`; +const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`; +const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`; -function linkMetadata() { +const FILE_PATTERN = createFilePattern(PROSE_PATH); +function linkMetadata(inlineCodePaths: ReadonlySet) { + // The engine accepts regexes on both platforms. Classify complete code spans + // first, then pass literal matches rather than maintaining a second path grammar. + const alternatives = [...inlineCodePaths].map((path) => + path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ); return { linkRecognition: { text: FILE_PATTERN, inlineCode: new RegExp( - String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`, + String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`, ), }, linkSource: markdownLinkSource, }; } +function collectInlineCodePaths( + source: string, + workspaceRoot?: string, + commandNames?: ReadonlySet, +): ReadonlySet { + const paths = new Set(); + const previousEnds = new Map(); + // Collect candidates between consecutive runs of equal length. The engine owns + // Markdown context (escapes, fences, authored links); extra candidates are harmless + // because recognition only applies to complete Code nodes. This avoids an AST + // parse on every streaming reveal, including updates inside long code fences. + for (const match of source.matchAll(/`+/g)) { + const length = match[0].length; + const previousEnd = previousEnds.get(length); + previousEnds.set(length, match.index + length); + if (previousEnd === undefined) continue; + let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " "); + // CommonMark code spans replace line endings and strip one surrounding space. + if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) { + value = value.slice(1, -1); + } + const classification = classifyAssistantFileLink(value, { workspaceRoot }); + if ( + (classification && classification.kind !== "external") || + (value.startsWith("/") && commandNames?.has(value.slice(1))) + ) + paths.add(value); + } + return paths; +} + export function markdownLinkSource( url: string, sourceType?: "markdown" | "text" | "inline-code", @@ -80,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string) export function prepareMarkdownSource( source: string, workspaceRoot?: string, - options: { supportsSvg?: boolean } = {}, + options: { supportsSvg?: boolean; commandNames?: ReadonlySet } = {}, ) { source = normalizeMarkdownHtml(source); const images: MarkdownImage[] = []; @@ -90,14 +130,21 @@ export function prepareMarkdownSource( extensions: [gfmTable()], mdastExtensions: [gfmTableFromMarkdown()], })); + const metadata = linkMetadata( + collectInlineCodePaths(source, workspaceRoot, options.commandNames), + ); const edits: { start: number; end: number; text: string }[] = []; if ( !source.includes("![") && !source.includes("||") && !source.includes("&") && + // Match content, header, and delimiter lines; quote-only lines count as blank. + !/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test( + source, + ) && !(workspaceRoot && source.includes("](")) ) { - return { images, ...linkMetadata(), render: () => source }; + return { images, ...metadata, render: () => source }; } const definitions = new Map(); @@ -130,10 +177,64 @@ export function prepareMarkdownSource( end, }); } - function visit(node: Nodes) { - const start = node.position?.start.offset; - const end = node.position?.end.offset; - if (start !== undefined && end !== undefined) { + // Line boundaries already turned into paragraph breaks, by offset of their newline. + const splitBoundaries = new Set(); + function isImageLike(node: PhrasingContent): boolean { + if (node.type === "image" || node.type === "imageReference") return true; + return ( + (node.type === "link" || node.type === "linkReference") && + node.children.length === 1 && + isImageLike(node.children[0]) + ); + } + // An image alone on its line becomes its own paragraph, so every platform renders + // it as a block. The library only treats sole-child paragraph images as blocks; + // with hardSoftBreaks every newline is a break node, which made these inline. + function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) { + for (const child of paragraph.children) { + const childStart = child.position?.start.offset; + const childEnd = child.position?.end.offset; + if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue; + const lineStart = source.lastIndexOf("\n", childStart - 1) + 1; + const newline = source.indexOf("\n", childEnd); + const lineEnd = newline === -1 ? source.length : newline; + const prefix = source.slice(lineStart, childStart); + const suffix = source.slice(childEnd, lineEnd); + const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix); + const aloneAfter = /^\s*\\?$/.test(suffix); + if (!aloneBefore || !aloneAfter) continue; + // Continuation lines inside quotes and lists keep their quote markers on the blank line. + const blank = prefix.replace(/[^>]/g, " ").trimEnd(); + const split = (boundary: number) => { + if (splitBoundaries.has(boundary)) return; + splitBoundaries.add(boundary); + // A trailing backslash was a hard break; at a paragraph end it would render literally. + const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary; + edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` }); + }; + if (lineStart > start) split(lineStart - 1); + if (lineEnd < end) split(lineEnd); + } + } + function visit(node: Nodes, previous?: Nodes) { + const position = node.position; + if (position?.start.offset !== undefined && position.end.offset !== undefined) { + const start = position.start.offset; + const end = position.end.offset; + if (node.type === "paragraph") splitStandaloneImages(node, start, end); + if ( + node.type === "table" && + previous?.type === "paragraph" && + previous.position?.end.line === position.start.line - 1 + ) { + // MD4C only recognizes a table header on the first line of a paragraph. + const lineStart = + Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1; + const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, ""); + const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd(); + const boundary = lineStart - newline.length; + edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` }); + } if (node.type === "link") { const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); if (title !== null) @@ -161,12 +262,13 @@ export function prepareMarkdownSource( if (text !== source.slice(start, end)) edits.push({ start, end, text }); } } - if ("children" in node) node.children.forEach(visit); + if ("children" in node) + node.children.forEach((child, index) => visit(child, node.children[index - 1])); } visit(getTree()); return { images, - ...linkMetadata(), + ...metadata, render( urls: ReadonlyMap = new Map(), unavailableImages: ReadonlySet = new Set(), diff --git a/packages/app/src/components/markdown/styles.ts b/packages/app/src/components/markdown/styles.ts index 03d6c69b23..a06104dd10 100644 --- a/packages/app/src/components/markdown/styles.ts +++ b/packages/app/src/components/markdown/styles.ts @@ -2,27 +2,35 @@ import type { MarkdownStyle } from "react-native-enriched-markdown"; import type { Theme } from "@/styles/theme"; import { isWeb } from "@/constants/platform"; +// iOS Safari needs the prefix to override the gesture host's selection suppression. +const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const; + export function createMarkdownStyles( theme: Theme, - compact = false, -): { markdownStyle: MarkdownStyle; selectionColor: string } { + { + compact = false, + muted = false, + blockGap, + }: { compact?: boolean; muted?: boolean; blockGap: number }, +): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } { const fontSize = compact ? theme.fontSize.base : theme.fontSize.content; + // Every block is followed by the same gap; the library drops it after the last block. + const block = { marginTop: 0, marginBottom: blockGap }; const paragraph = { fontSize, fontFamily: theme.fontFamily.ui, lineHeight: Math.round(fontSize * 1.4), - color: theme.colors.foreground, - marginTop: 0, - marginBottom: theme.spacing[3], + color: muted ? theme.colors.foregroundMuted : theme.colors.foreground, + ...block, }; const heading = (scale: number) => ({ ...paragraph, fontSize: Math.round(fontSize * scale), lineHeight: Math.round(fontSize * scale * 1.3), fontWeight: "bold", - marginTop: theme.spacing[3], }); return { + containerStyle: isWeb ? WEB_CONTAINER : undefined, selectionColor: theme.colors.terminal.selectionBackground, markdownStyle: { paragraph, @@ -64,7 +72,7 @@ export function createMarkdownStyles( }, code: { fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, - fontSize: theme.fontSize.code, + fontScale: theme.fontSize.code / fontSize, color: theme.colors.foreground, backgroundColor: theme.colors.surface2, borderColor: "transparent", @@ -93,8 +101,8 @@ export function createMarkdownStyles( borderColor: theme.colors.foregroundMuted, }, inlineImage: { size: paragraph.lineHeight }, - image: { height: 320, resizeMode: "contain", borderRadius: 8 }, - thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 }, + image: { ...block, height: 320, resizeMode: "contain", borderRadius: 8 }, + thematicBreak: { ...block, color: theme.colors.border, height: 1 }, }, }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 314f414d48..0ec5cbfb44 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { MessagePreview, MessageSender } from "@/components/user-message"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; @@ -26,6 +27,8 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { useToolCallExpanded } from "@/tool-calls/expansion-context"; +import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types"; @@ -88,6 +91,7 @@ export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; interface UserMessageProps { + sender?: AgentMessage["sender"]; serverId?: string; agentId?: string; messageId?: string; @@ -386,6 +390,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage const MESSAGE_TEXT_DATASET = { messageText: "true" }; export const UserMessage = memo(function UserMessage({ + sender, serverId, agentId, messageId, @@ -472,42 +477,45 @@ export const UserMessage = memo(function UserMessage({ onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > + {sender ? : null} - {hasImages ? ( - - {images.map((image) => ( - - ))} - - ) : null} - {hasAttachments ? ( - - {attachments.map((attachment, index) => { - const content = getAgentAttachmentPillContent(attachment, t); - return ( - - - - ); - })} - - ) : null} - {hasText ? ( - - {message} - - ) : null} + + {hasImages ? ( + + {images.map((image) => ( + + ))} + + ) : null} + {hasAttachments ? ( + + {attachments.map((attachment, index) => { + const content = getAgentAttachmentPillContent(attachment, t); + return ( + + + + ); + })} + + ) : null} + {hasText ? ( + + {message} + + ) : null} + {hasText ? ( string; + getContent?: () => string; completedAt?: Date; durationMs?: number | null; onFork?: (target: AssistantForkTarget) => Promise | void; @@ -640,10 +648,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - + {getContent ? ( + + ) : null} {canFork ? : null} {primaryLabel ? ( ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + // Details that do not sit in a card leave the header as a lone highlighted pill. + pressableExpandedDetached: { + borderRadius: theme.borderRadius.full, + }, detailWrapperBorderless: { borderWidth: 0, }, @@ -1185,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({ > buildToolCallPresentation({ @@ -2275,9 +2305,10 @@ export const ToolCall = memo(function ToolCall({ detail: effectiveDetail, metadata, cwd, + targetAgentTitle, resolveIcon: resolveToolCallIcon, }), - [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage], + [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle], ); const handleOpenFile = useMemo(() => { const openFilePath = presentation.openFilePath; @@ -2299,10 +2330,11 @@ export const ToolCall = memo(function ToolCall({ showLoadingSkeleton: presentation.isLoadingDetails, }); } else { - setIsExpanded((prev) => !prev); + toggleExpanded(); } }, [ shouldRenderInline, + toggleExpanded, openToolCall, toolName, presentation.detail, @@ -2393,6 +2425,8 @@ export const ToolCall = memo(function ToolCall({ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.toolName !== next.toolName) return false; + if (previous.serverId !== next.serverId) return false; + if (previous.agentMessage !== next.agentMessage) return false; if (previous.args !== next.args) return false; if (previous.result !== next.result) return false; if (previous.error !== next.error) return false; @@ -2406,5 +2440,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.forceInline !== next.forceInline) return false; if (previous.maxDetailHeight !== next.maxDetailHeight) return false; + if (previous.itemId !== next.itemId) return false; + if (previous.standalone !== next.standalone) return false; return true; } diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 17924b233d..2ca6b6a9c2 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0, }, emptyState: { - paddingVertical: theme.spacing[4], + padding: theme.spacing[4], alignItems: "center", gap: theme.spacing[2], }, diff --git a/packages/app/src/components/page-layout/index.tsx b/packages/app/src/components/page-layout/index.tsx index e0c46b695a..4f3bb57af4 100644 --- a/packages/app/src/components/page-layout/index.tsx +++ b/packages/app/src/components/page-layout/index.tsx @@ -1,10 +1,14 @@ import { useMemo, type ReactNode } from "react"; -import { ScrollView, Text, View } from "react-native"; +import { Platform, ScrollView, Text, View } from "react-native"; +import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; import { MenuHeader } from "@/components/headers/menu-header"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { SPACING } from "@/styles/theme"; + +const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView; interface PageLayoutProps { title?: string; @@ -42,7 +46,13 @@ export function PageLayout({ ) : ( )} - + {showTitle ? ( @@ -54,7 +64,7 @@ export function PageLayout({ ) : null} {children} - + ); } diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index 65c75166a5..0083926627 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => { it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" }); + expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" }); expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" }); expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" }); + expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" }); expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" }); }); diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts index 025ff14bef..aceed473be 100644 --- a/packages/app/src/components/provider-icons.test.ts +++ b/packages/app/src/components/provider-icons.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, cleanup, renderHook } from "@testing-library/react"; -import { Bot } from "lucide-react-native"; +import { Bot, PackagePlus } from "lucide-react-native"; import { SvgXml } from "react-native-svg"; import { afterEach, describe, expect, it } from "vitest"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; @@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) { } describe("useProviderIcon", () => { + it.each(["devin", "hermes", "kiro", "antigravity"])( + "#7087: %s has a provider mark before a host snapshot arrives", + (provider) => { + const Icon = renderHook(() => useProviderIcon(provider)).result.current; + expect(Icon).not.toBe(Bot); + expect(Icon).not.toBe(PackagePlus); + expect(typeof Icon).toBe("function"); + }, + ); + it("renders registered snapshot SVG metadata with the requested size and color", () => { const svg = ''; replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 883b4820f7..d8548cbd16 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,12 +1,14 @@ import { useStore } from "zustand"; import { providerSnapshotIcons } from "@/data/provider-icons"; -import { Bot, PackagePlus } from "lucide-react-native"; +import { Bot } from "lucide-react-native"; import { createElement, useCallback, type ComponentType } from "react"; import { SvgXml } from "react-native-svg"; import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names"; +import { AntigravityIcon } from "@/components/icons/antigravity-icon"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; +import { KiroIcon } from "@/components/icons/kiro-icon"; import { MiniMaxIcon } from "@/components/icons/minimax-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; @@ -23,10 +25,11 @@ export interface ProviderIconProps { export type ProviderIconComponent = ComponentType; const BUILTIN_PROVIDER_ICONS: Record = { + antigravity: AntigravityIcon as unknown as ProviderIconComponent, claude: ClaudeIcon as unknown as ProviderIconComponent, codex: CodexIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent, - kiro: PackagePlus, + kiro: KiroIcon as unknown as ProviderIconComponent, minimax: MiniMaxIcon as unknown as ProviderIconComponent, omp: OmpIcon as unknown as ProviderIconComponent, opencode: OpenCodeIcon as unknown as ProviderIconComponent, diff --git a/packages/app/src/components/question-form-card.browser.test.tsx b/packages/app/src/components/question-form-card.browser.test.tsx index 83120931d1..a91ba41215 100644 --- a/packages/app/src/components/question-form-card.browser.test.tsx +++ b/packages/app/src/components/question-form-card.browser.test.tsx @@ -10,6 +10,12 @@ import { QuestionFormCard } from "./question-form-card"; // Load translations so controls expose their real accessible names. void testI18n; +// The Markdown engine does not load in this harness; question and option text are covered by +// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers. +vi.mock("@/components/markdown/renderer", () => ({ + MarkdownRenderer: ({ text }: { text: string }) => {text}, +})); + // App sources compile against the classic JSX runtime, which expects React on the global. beforeEach(() => vi.stubGlobal("React", React)); @@ -33,7 +39,10 @@ afterEach(() => { } }); -function buildPermission(question: Record): PendingPermission { +function buildPermission( + question: Record, + following: Record[] = [], +): PendingPermission { return { key: "perm-1", agentId: "agent-1", @@ -42,12 +51,12 @@ function buildPermission(question: Record): PendingPermission { provider: "claude", name: "AskUserQuestion", kind: "question", - input: { questions: [question] }, + input: { questions: [question, ...following] }, }, }; } -function mountCard(question: Record) { +function mountCard(question: Record, following: Record[] = []) { const onRespond = vi.fn<(response: AgentPermissionResponse) => void>(); const container = document.createElement("div"); document.body.appendChild(container); @@ -55,7 +64,7 @@ function mountCard(question: Record) { act(() => root.render( ) { mounted.push({ root, container }); const view = within(container); - const optionRole = question.multiSelect ? "checkbox" : "radio"; const otherInput = () => view.getByRole("textbox", { name: String(question.question) }); - const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click()); + const check = (label: string) => + act(() => + ( + view.queryByRole("radio", { name: label }) ?? view.getByRole("checkbox", { name: label }) + ).click(), + ); const type = (text: string) => { const input = otherInput(); const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; @@ -84,7 +97,7 @@ function mountCard(question: Record) { if (!response || response.behavior !== "allow") throw new Error("card did not submit"); return (response.updatedInput as { answers: Record }).answers; }; - return { check, type, otherInput, submit, submittedAnswers }; + return { check, type, otherInput, submit, submittedAnswers, view, onRespond }; } const multiSelectQuestion = { @@ -148,3 +161,31 @@ describe("QuestionFormCard other answers", () => { expect(card.submittedAnswers()).toEqual({ Provider: "Codex" }); }); }); + +describe("QuestionFormCard explicit navigation", () => { + it("keeps a picked option visible until Next and submits only on Submit", () => { + const card = mountCard(singleSelectQuestion, [multiSelectQuestion]); + card.check("Codex"); + expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe( + "true", + ); + expect(card.onRespond).not.toHaveBeenCalled(); + act(() => card.view.getByRole("button", { name: "Next" }).click()); + card.check("Apple"); + expect(card.onRespond).not.toHaveBeenCalled(); + card.submit(); + expect(card.submittedAnswers()).toEqual({ Provider: "Codex", Fruits: "Apple" }); + }); + + it("selects an option when its description is clicked without submitting", () => { + const card = mountCard({ + ...singleSelectQuestion, + options: [{ label: "Codex", description: "Use this provider" }], + }); + act(() => card.view.getByText("Use this provider").click()); + expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe( + "true", + ); + expect(card.onRespond).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 7014de89d1..7417cf64c4 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,7 +1,13 @@ import { Alert } from "@/components/ui/alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo, useRef, type RefObject } from "react"; -import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; +import { + View, + Text, + Pressable, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; @@ -12,6 +18,7 @@ import { isWeb } from "@/constants/platform"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input/types"; import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; import { areQuestionsAnswered, buildQuestionFormAnswers, @@ -36,6 +43,16 @@ const IS_WEB = isWeb; // drag to the preview's nested scroll, so lines past a cap would be unreachable. const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined; +// Links are independent actions. Ignore repeat clicks so double-click text selection +// toggles an option only once. +function isNonAnsweringClick(event: GestureResponderEvent): boolean { + if (!IS_WEB) return false; + const click = event.nativeEvent as unknown as MouseEvent; + if (click.detail > 1) return true; + const target = click.target as Element | null; + return Boolean(target?.closest("a")); +} + function getQuestionInputPlaceholder({ question, answerPlaceholder, @@ -71,9 +88,13 @@ function QuestionOptionRow({ }: QuestionOptionRowProps) { const { theme } = useUnistyles(); - const handlePress = useCallback(() => { - onToggle(qIndex, optIndex, multiSelect); - }, [onToggle, qIndex, optIndex, multiSelect]); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (isNonAnsweringClick(event)) return; + onToggle(qIndex, optIndex, multiSelect); + }, + [onToggle, qIndex, optIndex, multiSelect], + ); const pressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -86,17 +107,6 @@ function QuestionOptionRow({ [isSelected, theme.colors.surface2], ); - const optionLabelStyle = useMemo( - () => [ - styles.optionLabel, - { color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted }, - ], - [isSelected, theme.colors.foreground, theme.colors.foregroundMuted], - ); - const optionDescriptionStyle = useMemo( - () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]); // Static left-side control: square for multi-select, circle for single-select. @@ -135,9 +145,11 @@ function QuestionOptionRow({ {isSelected && !multiSelect ? : null} - {option.label} + {option.description ? ( - {option.description} + + + ) : null} {option.preview ? ( // Shown for every option at once so they can be compared before picking. @@ -392,12 +404,8 @@ export function QuestionFormCard({ }); otherInputRef.current?.replaceText(""); } - - if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) { - setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1)); - } }, - [activeQuestionIndex, otherTexts, questions, selections], + [otherTexts, selections], ); const setOtherText = useCallback( @@ -522,10 +530,6 @@ export function QuestionFormCard({ ], [theme.colors.surface1, theme.colors.border], ); - const questionTextStyle = useMemo( - () => [styles.questionText, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); // Single-select radios need a group; checkboxes are valid standalone. const optionsGroupAccessibility = useMemo( () => @@ -570,9 +574,9 @@ export function QuestionFormCard({ onSelect={handleSelectQuestion} /> - - {activeQuestion?.question} - + + + {activeQuestion ? ( @@ -675,9 +679,6 @@ const styles = StyleSheet.create((theme) => ({ }, questionText: { flex: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], @@ -723,15 +724,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: theme.spacing[1], }, - optionLabel: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, - }, - optionDescription: { - fontSize: theme.fontSize.base, - lineHeight: 20, - }, optionPreviewText: { lineHeight: 20, ...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null), diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index 6576728384..c3930688c2 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -49,7 +49,7 @@ export function ResizeHandle({ const { theme } = useUnistyles(); const finePointer = useHasFinePointer(); const pointerStatesRef = useRef(new Map()); - const touchDragRef = useRef(null); + const touchStateRef = useRef(null); const cursorBeforeDragRef = useRef(null); const hoverTimerRef = useRef | null>(null); const [active, setActive] = useState(false); @@ -151,23 +151,30 @@ export function ResizeHandle({ const touchGesture = useMemo(() => { const gesture = Gesture.Pan() .runOnJS(true) - .onBegin(() => setDragging(true)) - .onStart(() => { - touchDragRef.current = startResizeHandleDrag({ - sizes, - index, - preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), - commit: (nextSizes) => onResizeSplit(groupId, nextSizes), - }); + .onBegin((event) => { + setDragging(true); + touchStateRef.current = { + containerSize, + pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY, + drag: startResizeHandleDrag({ + sizes, + index, + preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), + commit: (nextSizes) => onResizeSplit(groupId, nextSizes), + }), + }; }) .onUpdate((event) => { - if (containerSize <= 0) return; - const translation = direction === "horizontal" ? event.translationX : event.translationY; - touchDragRef.current?.move(translation / containerSize); + const state = touchStateRef.current; + if (!state || state.containerSize <= 0) return; + // Gesture Handler resets web translation when the pan activates. Measure from + // the initial press so the activation distance is included on every platform. + const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY; + state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize); }) - .onEnd(() => touchDragRef.current?.finish()) + .onEnd(() => touchStateRef.current?.drag.finish()) .onFinalize(() => { - touchDragRef.current = null; + touchStateRef.current = null; setDragging(false); }); diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index d482bb2d42..f6d3d0325c 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; @@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { : undefined; if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), }); } diff --git a/packages/app/src/components/settings/collapsible-row.tsx b/packages/app/src/components/settings/collapsible-row.tsx index 219c19776c..d219c3abd5 100644 --- a/packages/app/src/components/settings/collapsible-row.tsx +++ b/packages/app/src/components/settings/collapsible-row.tsx @@ -1,20 +1,18 @@ -import { ChevronRight } from "lucide-react-native"; import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { settingsStyles } from "@/styles/settings"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; - -const ThemedChevron = withUnistyles(ChevronRight); -const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +import { NestedSettingsRows } from "./row-density"; +import { SettingsRowHeader } from "./row-header"; interface SettingsCollapsibleRowProps { label: string; + /** A short status shown before the chevron, so the row says something while collapsed. */ + summary?: string; defaultExpanded?: boolean; testID?: string; - /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */ + /** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */ children: ReactNode; } @@ -25,6 +23,7 @@ interface SettingsCollapsibleRowProps { */ export function SettingsCollapsibleRow({ label, + summary, defaultExpanded = false, testID, children, @@ -37,66 +36,39 @@ export function SettingsCollapsibleRow({ () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), [expanded], ); - const headerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - settingsStyles.row, - hovered && styles.headerHovered, - pressed && styles.headerPressed, - ], - [], - ); - const chevronStyle = useMemo( - () => [styles.chevron, expanded && styles.chevronExpanded], - [expanded], - ); return ( - - {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( - <> - - {label} - - - - - - )} - - {expanded - ? Children.toArray(children).map((child, index) => ( - - {child} - - )) - : null} + /> + {expanded ? ( + + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + + ) : null} ); } const styles = StyleSheet.create((theme) => ({ - headerHovered: { - backgroundColor: theme.colors.surface2, - }, - headerPressed: { - backgroundColor: theme.colors.surface3, - }, - // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this - // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. - chevron: { marginRight: -theme.spacing[1] }, - chevronExpanded: { - transform: [{ rotate: "90deg" }], + // The revealed rows keep the card's rails; the tint is what groups them under their header. + children: { + backgroundColor: theme.colors.surface0, }, })); diff --git a/packages/app/src/components/settings/detail-row.tsx b/packages/app/src/components/settings/detail-row.tsx new file mode 100644 index 0000000000..f256babe50 --- /dev/null +++ b/packages/app/src/components/settings/detail-row.tsx @@ -0,0 +1,33 @@ +import { SettingsRowHeader } from "./row-header"; + +interface SettingsDetailRowProps { + label: string; + /** What the detail is set to, so the row says it without being opened. */ + summary?: string; + onPress: () => void; + /** A row that cannot be opened keeps its summary and drops the chevron. */ + disabled?: boolean; + testID?: string; +} + +/** + * A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line + * summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`. + */ +export function SettingsDetailRow({ + label, + summary, + onPress, + disabled, + testID, +}: SettingsDetailRowProps) { + return ( + + ); +} diff --git a/packages/app/src/components/settings/headings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx index c8d36df74f..7242247583 100644 --- a/packages/app/src/components/settings/headings/settings-info-tip.tsx +++ b/packages/app/src/components/settings/headings/settings-info-tip.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; import { Pressable, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { Info } from "lucide-react-native"; @@ -6,7 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -const ThemedInfo = withUnistyles(Info); +type TipIconComponent = ComponentType<{ size: number; color: string }>; + +function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) { + return ; +} + +const ThemedTipIcon = withUnistyles(TipIcon); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -14,27 +20,37 @@ interface SettingsInfoTipProps { /** Header the tip belongs to; used for the accessibility label. */ title: string; info: ReactNode; + /** Defaults to the info icon. */ + icon?: TipIconComponent; + /** Defaults to "About {title}". */ + accessibilityLabel?: string; testID?: string; } /** * The one way a settings group or section explains itself: an info icon on the * header that opens a tooltip. Never a paragraph under the header — see - * docs/design.md §7. + * docs/design.md §7. Rows use it the same way to keep secondary details on one line. */ -export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) { +export function SettingsInfoTip({ + title, + info, + icon = Info, + accessibilityLabel, + testID, +}: SettingsInfoTipProps) { const { t } = useTranslation(); return ( - + diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index f31a0e7b38..567b13bd4e 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -24,15 +24,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; +import { ControlSizeProvider } from "@/components/ui/control-size"; +import { useSettingsRowLayout } from "./row-density"; export { SettingsGroup } from "./headings/settings-group"; export { SettingsSection } from "./headings/settings-section"; export { SettingsCollapsibleRow } from "./collapsible-row"; +export { SettingsDetailRow } from "./detail-row"; +export { SettingsInfoTip } from "./headings/settings-info-tip"; -interface AppSettingsRowProps extends Omit { - hint?: ReactNode; +/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */ +interface AppRowExtras { labelAccessory?: ReactNode; } +interface AppSettingsRowProps extends Omit, AppRowExtras { + hint?: ReactNode; +} + export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { return ( @@ -57,26 +65,32 @@ export function SettingsRow({ testID, }: AppSettingsRowProps) { const compact = useIsCompactFormFactor(); - const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + const layout = useSettingsRowLayout(); + const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]); + const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]); return ( {labelAccessory ? ( - {label} + {label} {labelAccessory} ) : ( - {label} + {label} )} - {typeof hint === "string" ? {hint} : hint} + {typeof hint === "string" ? {hint} : hint} {error ? ( {error} ) : null} - {children ? {children} : null} + {children ? ( + + {children} + + ) : null} ); } @@ -117,7 +131,7 @@ export function SettingsSelect({ onValueChange, disabled, ...row -}: SettingsSelectProps) { +}: SettingsSelectProps & AppRowExtras) { const selectedLabel = options.find((option) => option.value === value)?.label ?? value; return ( @@ -152,7 +166,7 @@ export function SettingsInput({ secureTextEntry, ref, ...row -}: SettingsInputProps) { +}: SettingsInputProps & AppRowExtras) { const compact = useIsCompactFormFactor(); const input = useRef(null); useImperativeHandle( @@ -175,7 +189,7 @@ export function SettingsInput({ editable={!disabled} secureTextEntry={secureTextEntry} accessibilityLabel={row.label} - size={compact ? "md" : "sm"} + size={compact ? "md" : undefined} style={styles.input} /> @@ -185,7 +199,7 @@ export function SettingsInput({ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { return ( - diff --git a/packages/app/src/components/settings/row-density.browser.test.tsx b/packages/app/src/components/settings/row-density.browser.test.tsx new file mode 100644 index 0000000000..d2742e3172 --- /dev/null +++ b/packages/app/src/components/settings/row-density.browser.test.tsx @@ -0,0 +1,77 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Button } from "@/components/ui/button"; +import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function mountRows() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + + + + + + + + + + + , + ), + ); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`); + return found; +} + +function heightOf(testID: string): number { + return element(testID).getBoundingClientRect().height; +} + +function titleFontSize(rowTestID: string, label: string): number { + const title = [...element(rowTestID).querySelectorAll("div")].find( + (node) => node.textContent === label && node.children.length === 0, + ); + if (!title) throw new Error(`No title ${label}`); + return Number.parseFloat(getComputedStyle(title).fontSize); +} + +describe("settings row density", () => { + it("lines up nested rows with and without a button, tighter than card rows", () => { + mountRows(); + + expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button")); + expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row")); + expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button")); + }); + + it("sizes nested text and controls one step below card rows", () => { + mountRows(); + + expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button")); + expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan( + titleFontSize("card-row", "Card row"), + ); + }); +}); diff --git a/packages/app/src/components/settings/row-density.tsx b/packages/app/src/components/settings/row-density.tsx new file mode 100644 index 0000000000..d4bcdce33e --- /dev/null +++ b/packages/app/src/components/settings/row-density.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import type { ContainedControlSize } from "@/components/ui/control-size"; +import { settingsStyles } from "@/styles/settings"; + +// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows +// of the card: tighter padding, smaller text, smaller controls. The container decides; a row +// never knows on its own that it is nested. +const NestedRowsContext = createContext(false); + +export function NestedSettingsRows({ children }: { children: ReactNode }) { + return {children}; +} + +export interface SettingsRowLayout { + row: StyleProp; + title: StyleProp; + hint: StyleProp; + /** The size of the controls in the row's trailing slot. */ + controlSize: ContainedControlSize; +} + +/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */ +export function useSettingsRowLayout(): SettingsRowLayout { + const nested = useContext(NestedRowsContext); + return useMemo( + () => + nested + ? { + row: [settingsStyles.row, styles.nestedRow], + title: [settingsStyles.rowTitle, styles.nestedText], + hint: [settingsStyles.rowHint, styles.nestedText], + controlSize: "xs", + } + : { + row: settingsStyles.row, + title: settingsStyles.rowTitle, + hint: settingsStyles.rowHint, + controlSize: "sm", + }, + [nested], + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Every nested row is as tall as one holding an `xs` control, so rows with and without a + // trailing button line up. + nestedRow: { + paddingVertical: theme.spacing[2], + minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2, + }, + nestedText: { + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/components/settings/row-header.tsx b/packages/app/src/components/settings/row-header.tsx new file mode 100644 index 0000000000..9c3c415c34 --- /dev/null +++ b/packages/app/src/components/settings/row-header.tsx @@ -0,0 +1,113 @@ +import { ChevronRight } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; +import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { useSettingsRowLayout } from "./row-density"; + +const ThemedChevron = withUnistyles(ChevronRight); +const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +export interface SettingsRowHeaderProps { + label: string; + /** A short status shown before the chevron. */ + summary?: string; + onPress: () => void; + disabled?: boolean; + /** Turns the chevron down, for a row whose children are revealed in place. */ + expanded?: boolean; + accessibilityState?: AccessibilityState; + /** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */ + webAccessibilityProps?: Record | null; + testID?: string; +} + +/** + * The pressable line shared by settings rows that lead somewhere: a label, an optional summary and + * a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it + * pointing right. + */ +export function SettingsRowHeader({ + label, + summary, + onPress, + disabled, + expanded = false, + accessibilityState, + webAccessibilityProps, + testID, +}: SettingsRowHeaderProps) { + const layout = useSettingsRowLayout(); + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + layout.row, + !disabled && hovered && styles.hovered, + !disabled && pressed && styles.pressed, + ], + [disabled, layout], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + return ( + + {({ hovered }: HoverState) => ( + <> + + {label} + + {summary ? ( + + {summary} + + ) : null} + {disabled ? null : ( + + + + )} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginRight: theme.spacing[2], + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 73c008dd22..9532c422dd 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -1,7 +1,9 @@ /** * @vitest-environment jsdom */ -import { act } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; +import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -83,6 +85,8 @@ function workspace(input: { scripts?: WorkspaceDescriptor["scripts"]; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -171,10 +175,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], - workspaceOrderByProject: { - ["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`], - ["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`], - }, }); }); } @@ -348,7 +348,6 @@ describe("sidebar workspace render isolation", () => { useSessionStore.getState().clearSession(SERVER_ID); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); }); }); @@ -439,3 +438,215 @@ describe("sidebar workspace render isolation", () => { }); }); }); + +describe("sidebar group visibility", () => { + const entries = (statuses: WorkspaceDescriptor["status"][]) => + statuses.map((status, index) => + createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: workspace({ + id: `row-${index + 1}`, + projectId: "project-a", + projectDisplayName: "Project A", + name: `Row ${index + 1}`, + status, + }), + }), + ); + const policy = { + kind: "workspaces" as const, + getStatusBucket: (entry: ReturnType) => entry.statusBucket, + }; + const names = (items: readonly ReturnType[]) => + items.map((item) => item.workspaceId); + + it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { + const items = entries(Array(count).fill("done")); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); + expect(result.current.visibleItems).toEqual(items.slice(0, 7)); + expect(result.current.canToggle).toBe(count > 7); + }); + + it.each(["needs_input", "failed", "attention", "running"] as const)( + "keeps row twelve visible for %s without filling gaps", + (status) => { + const items = entries([...Array(11).fill("done"), status]); + const { result } = renderHook(() => + useLimitedSidebarGroup({ items, selection: null, policy }), + ); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-12", + ]); + expect(result.current.visibleItems[7]).toBe(items[11]); + expect(result.current.canToggle).toBe(true); + }, + ); + + it("treats placements without a hydrated entry as Done", () => { + const items = entries([...Array(8).fill("done"), "running", "done"]); + const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); + for (const entriesByKey of [new Map(), hydrated]) { + const { result } = renderHook(() => + useLimitedSidebarGroup({ + items, + selection: null, + policy: createWorkspaceVisibilityPolicy(entriesByKey), + }), + ); + expect(result.current.canToggle).toBe(true); + expect(names(result.current.visibleItems)).toEqual( + entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"], + ); + } + }); + + it("shows eight active rows once with no redundant toggle", () => { + const items = entries([ + "running", + "needs_input", + "attention", + "failed", + "running", + "attention", + "needs_input", + "failed", + ]); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); + expect(result.current.visibleItems).toEqual(items); + expect(result.current.canToggle).toBe(false); + }); + + it("recomputes current ordered membership and retains reveal through status, order and size changes", () => { + const items = entries([ + ...Array(7).fill("done"), + "running", + "done", + "failed", + "done", + "attention", + ]); + const { result, rerender, unmount } = renderHook( + ({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }), + { initialProps: { rows: items } }, + ); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-8", + "row-10", + "row-12", + ]); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) }); + expect(result.current.canToggle).toBe(false); + expect(result.current.expanded).toBe(true); + rerender({ rows: items.slice(0, 3) }); + expect(result.current.canToggle).toBe(false); + rerender({ rows: items.toReversed() }); + expect(result.current.visibleItems).toEqual(items.toReversed()); + act(() => result.current.toggleExpanded()); + expect(names(result.current.visibleItems)).toEqual([ + "row-12", + "row-11", + "row-10", + "row-9", + "row-8", + "row-7", + "row-6", + ]); + const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" }; + rerender({ rows: [newest, ...items] }); + expect(result.current.visibleItems[0]).toBe(newest); + expect(result.current.visibleItems).toHaveLength(10); + expect(result.current.expanded).toBe(false); + act(() => result.current.toggleExpanded()); + unmount(); + const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); + expect(remounted.result.current.expanded).toBe(false); + expect(remounted.result.current.visibleItems).toHaveLength(10); + }); + + it("follows effective pending creation, recovery and agent activity without changing Created order", () => { + const items = entries(Array(12).fill("done")); + const descriptor = workspace({ + id: "row-12", + projectId: "project-a", + projectDisplayName: "Project A", + name: "Row 12", + }); + const pending = { + draftId: "draft", + serverId: SERVER_ID, + workspaceId: descriptor.id, + agentId: null, + clientMessageId: "message", + text: "Create", + timestamp: 1234, + lifecycle: "active" as const, + }; + const { result, rerender } = renderHook( + ({ last }) => + useLimitedSidebarGroup({ + items: [...items.slice(0, 11), last], + selection: null, + policy, + }), + { initialProps: { last: items[11] } }, + ); + expect(result.current.visibleItems).toHaveLength(7); + const creating = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: pending }, + }); + expect(creating.statusBucket).toBe("running"); + rerender({ last: creating }); + expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]); + const abandoned = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } }, + }); + rerender({ last: abandoned }); + expect(result.current.visibleItems).toHaveLength(7); + rerender({ last: creating }); + for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) { + const last = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + workspaceAgentActivity: new Map([ + [descriptor.id, { agentId: "agent", status, enteredAt: null }], + ]), + }); + expect(last.statusBucket).toBe(status); + rerender({ last }); + expect(names(result.current.visibleItems)).toEqual( + status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"], + ); + } + }); + + it("preserves the twenty-row pinned policy regardless of status", () => { + const items = entries(Array(21).fill("running")); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null })); + expect(result.current.visibleItems).toEqual(items.slice(0, 20)); + expect(result.current.canToggle).toBe(true); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toHaveLength(20); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c750121368..8697a4b27b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityIconBox } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + useHosts, + useHostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarWorkspacePinController, @@ -95,6 +99,7 @@ import { import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { SidebarWorkspaceRowFrame, @@ -268,7 +273,7 @@ interface WorkspaceRowInnerProps { shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; isArchiving: boolean; isCreating?: boolean; @@ -377,8 +382,6 @@ function getProjectWorkspaceRowStyle({ ]; } -function noop() {} - const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", @@ -460,12 +463,12 @@ const openInNewWindowLeadingIcon = ( ); +const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( - + ); } @@ -1232,7 +1235,7 @@ function WorkspaceRowWithMenu({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1399,7 +1402,6 @@ function WorkspaceRowItem({ isCreating = false, selectionEnabled, activeWorkspaceSelection, - onWorkspacePress, drag, isDragging = false, dragHandleProps, @@ -1408,9 +1410,8 @@ function WorkspaceRowItem({ if (!workspace.serverId) { return; } - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( @@ -1461,7 +1462,7 @@ function areWorkspaceRowItemPropsEqual( previous.hostBadge === next.hostBadge && previous.leadingProjectName === next.leadingProjectName && previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri && - previous.shortcutNumber === next.shortcutNumber && + (!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) && previous.showShortcutBadge === next.showShortcutBadge && previous.canCopyBranchName === next.canCopyBranchName && previous.canPin === next.canPin && @@ -1503,7 +1504,7 @@ function WorkspaceRow({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1548,16 +1549,17 @@ function ProjectBlock({ selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, - parentGestureRef, onToggleCollapsed, onWorkspacePress, + parentGestureRef, onWorkspaceReorder, + useNestable, + dragGestureHostActive, + manualSort, onWorktreeCreated, drag, isDragging, dragHandleProps, - useNestable, - dragGestureHostActive, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, @@ -1573,16 +1575,17 @@ function ProjectBlock({ selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; - parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; + parentGestureRef?: MutableRefObject; onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + useNestable: boolean; + dragGestureHostActive?: boolean; + manualSort: boolean; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; - useNestable: boolean; - dragGestureHostActive?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -1590,20 +1593,33 @@ function ProjectBlock({ supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const visibilityPolicy = useMemo( + () => createWorkspaceVisibilityPolicy(workspaceEntriesByKey), + [workspaceEntriesByKey], + ); const { + leadingItem: leadingWorkspace, visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(project.workspaces); + } = useLimitedSidebarGroup({ + items: project.workspaces, + selection: selectionEnabled ? activeWorkspaceSelection : null, + policy: visibilityPolicy, + }); + const activeServerId = activeWorkspaceSelection?.serverId ?? null; + const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); + const preferredServerId = activeHostStatus === "online" ? activeServerId : null; const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, supportsMultiplicityByServerId, + preferredServerId, }), - [collapsed, project, supportsMultiplicityByServerId], + [collapsed, preferredServerId, project, supportsMultiplicityByServerId], ); // Collapsed rows hide their workspace rows, so the project row carries the most urgent @@ -1630,6 +1646,7 @@ function ProjectBlock({ ) => { return ( 0) { + const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null; projectChildren = ( <> - + {manualSort ? ( + <> + {leadingWorkspaceRow} + + + ) : ( + + {leadingWorkspaceRow} + {visibleWorkspaces.map((item) => renderWorkspaceRow(item))} + + )} {canToggleWorkspaces ? ( [0]; function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && - previous.workspaceEntriesByKey === next.workspaceEntriesByKey && + projectWorkspaceEntriesEqual(previous, next) && previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && - previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && + projectShortcutBadgesEqual(previous, next) && previous.hostBadgeByServerId === next.hostBadgeByServerId && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && previous.supportsPinningByServerId === next.supportsPinningByServerId && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && - previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && + previous.parentGestureRef === next.parentGestureRef && previous.onWorkspaceReorder === next.onWorkspaceReorder && + previous.useNestable === next.useNestable && + previous.dragGestureHostActive === next.dragGestureHostActive && + previous.manualSort === next.manualSort && previous.onWorktreeCreated === next.onWorktreeCreated && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && - previous.useNestable === next.useNestable && - previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && + previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId && areProjectBlockSelectionsEqual(previous, next) ); } +function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { + if ( + !next.showShortcutBadges || + previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey + ) + return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.shortcutIndexByWorkspaceKey.get(workspaceKey) === + next.shortcutIndexByWorkspaceKey.get(workspaceKey), + ); +} + +function projectWorkspaceEntriesEqual( + previous: ProjectBlockProps, + next: ProjectBlockProps, +): boolean { + if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.workspaceEntriesByKey.get(workspaceKey) === + next.workspaceEntriesByKey.get(workspaceKey), + ); +} + function areProjectBlockSelectionsEqual( previous: ProjectBlockProps, next: ProjectBlockProps, @@ -2126,10 +2184,11 @@ function ProjectModeList({ (state) => state.togglePinnedCollapsed, ); - const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); - const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); + const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual"); const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); + const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); + const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), @@ -2139,11 +2198,15 @@ function ProjectModeList({ const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { pinnedChats, unpinnedProjects } = pinnedGroups; const { + leadingItem: leadingPinnedChat, visibleItems: visiblePinnedChats, expanded: pinnedChatsExpanded, canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, - } = useLimitedSidebarGroup(pinnedChats); + } = useLimitedSidebarGroup({ + items: pinnedChats, + selection: selectionEnabled ? activeWorkspaceSelection : null, + }); const nativeScrollGestureProps = useMemo( () => parentGestureRef @@ -2297,16 +2360,17 @@ function ProjectModeList({ selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} - parentGestureRef={parentGestureRef} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} + manualSort={manualSort} + parentGestureRef={parentGestureRef} onWorkspaceReorder={handleWorkspaceReorder} + useNestable={platformIsNative} + dragGestureHostActive={dragGestureHostActive} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} - useNestable={platformIsNative} - dragGestureHostActive={dragGestureHostActive} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostBadgeByServerId={hostBadgeByServerId} @@ -2320,6 +2384,9 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, + manualSort, + parentGestureRef, + dragGestureHostActive, handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, @@ -2327,8 +2394,6 @@ function ProjectModeList({ onToggleWorkspacePin, onWorkspacePress, onToggleProjectCollapsed, - parentGestureRef, - dragGestureHostActive, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, @@ -2344,37 +2409,38 @@ function ProjectModeList({ [renderProjectBlock], ); - const renderPinnedChat = useCallback( - ({ - item: workspace, - drag, - isActive, - dragHandleProps, - }: DraggableRenderItemInfo) => { - return ( - - ); - }, + const renderPinnedChatRow = useCallback( + ( + workspace: SidebarWorkspacePlacement, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => ( + + ), [ activeWorkspaceSelection, creatingWorkspaceIds, @@ -2390,6 +2456,22 @@ function ProjectModeList({ ], ); + const renderPinnedChat = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => { + return renderPinnedChatRow(workspace, { + drag, + isDragging: isActive, + dragHandleProps, + }); + }, + [renderPinnedChatRow], + ); + const projectBody = projects.length === 0 ? ( @@ -2422,6 +2504,7 @@ function ProjectModeList({ /> {pinnedCollapsed ? null : ( <> + {leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null} ({ marginRight: -6, }, projectKebabButton: { - width: 24, - height: 24, + // Larger on compact so the kebab is an easy tap target. The negative margins keep its + // dots on the same rail as the 24px desktop box and the row at its 44px height. + width: { xs: 32, md: 24 }, + height: { xs: 32, md: 24 }, + marginRight: { xs: -4, md: 0 }, + marginVertical: { xs: -4, md: 0 }, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", @@ -2752,7 +2839,7 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", }, workspaceArchivingOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, borderRadius: theme.borderRadius.lg, backgroundColor: `${theme.colors.surface0}cc`, alignItems: "center", diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index be5a66ff6f..d6483a5519 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { const hosts = useHosts(); // `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed // from it would lose the row that undoes the filter as soon as the filter narrowed to one. - const { allProjects, resolvedProjectFilters } = useSidebarModel(); + const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel(); const { labels } = useWorkspaceLabelProjection(); const [managerOpen, setManagerOpen] = useState(false); const openManager = useCallback(() => setManagerOpen(true), []); @@ -240,6 +240,16 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, ]; + if (preferences.grouping === "project") { + definitions.push({ + id: "sort", + title: t("sidebar.display.sort.label"), + content: ( + + ), + }); + } + if (showHostFilter) { definitions.push({ id: "hostFilter", @@ -273,6 +283,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, [ t, preferences, + workspaceSortSupported, hosts, showHostFilter, showProjectFilter, @@ -308,6 +319,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { > {t("sidebar.display.grouping.label")} + {preferences.grouping === "project" ? ( + + {t("sidebar.display.sort.label")} + + ) : null} setWorkspaceSort("manual"), [setWorkspaceSort]); + const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]); + const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); + return ( + <> + + {t("sidebar.display.sort.manual")} + + {automaticSortSupported ? ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ) : ( + {t("sidebar.display.sort.updateHost")} + )} + + ); +} + /** * Every label you could filter by, wherever it lives, one row each. * diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts index 68d347a258..a2ef3212e0 100644 --- a/packages/app/src/components/sidebar/display-preferences/model.ts +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useCallback, useMemo } from "react"; import { useAppSettings, @@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences { showBackground: boolean; toggleBackground: () => void; grouping: SidebarGroupMode; + workspaceSort: WorkspaceSort; + setWorkspaceSort: (sort: WorkspaceSort) => void; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; setTitleSource: (source: WorkspaceTitleSource) => void; @@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences { export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { const showBackground = useSidebarViewStore((state) => state.showBackground); const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, titleSource: workspaceTitleSource, setTitleSource, @@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, workspaceTitleSource, setTitleSource, diff --git a/packages/app/src/components/sidebar/empty-states.browser.test.tsx b/packages/app/src/components/sidebar/empty-states.browser.test.tsx new file mode 100644 index 0000000000..85cd96c0e6 --- /dev/null +++ b/packages/app/src/components/sidebar/empty-states.browser.test.tsx @@ -0,0 +1,85 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getAllByRole } from "@testing-library/dom"; +import { i18n as testI18n } from "@/i18n/i18next"; +import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store"; +import { SidebarProjectEmptyState } from "./empty-states"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +interface Mounted { + root: Root; + container: HTMLDivElement; +} + +const mounted: Mounted[] = []; + +afterEach(async () => { + for (const entry of mounted.splice(0)) { + act(() => entry.root.unmount()); + entry.container.remove(); + } + await testI18n.changeLanguage("en"); +}); + +async function mountEmptyState(language: string, width: number): Promise { + await testI18n.changeLanguage(language); + + const container = document.createElement("div"); + container.style.width = `${width}px`; + // Match the workspace list's horizontal inset around the card. + container.style.paddingInline = "6px"; + container.style.boxSizing = "border-box"; + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render()); + mounted.push({ root, container }); + + const card = container.querySelector('[data-testid="sidebar-project-empty-state"]'); + if (!(card instanceof HTMLElement)) { + throw new Error("SidebarProjectEmptyState did not render its card"); + } + return card; +} + +function expectActionsInsideCard(card: HTMLElement) { + const cardRect = card.getBoundingClientRect(); + const buttons = getAllByRole(card, "button"); + + expect(buttons).toHaveLength(2); + for (const button of buttons) { + const rect = button.getBoundingClientRect(); + expect(rect.left).toBeGreaterThanOrEqual(cardRect.left); + expect(rect.right).toBeLessThanOrEqual(cardRect.right); + + // Containing the button must also keep the whole label readable, without clipping. + const label = document.createRange(); + label.selectNodeContents(button); + for (const fragment of Array.from(label.getClientRects())) { + expect(fragment.left).toBeGreaterThanOrEqual(rect.left); + expect(fragment.right).toBeLessThanOrEqual(rect.right); + expect(fragment.top).toBeGreaterThanOrEqual(rect.top); + expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom); + } + } +} + +describe("SidebarProjectEmptyState", () => { + const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) => + [MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })), + ); + it.each(cases)( + "keeps every action inside the card ($language, $width px sidebar)", + async ({ language, width }) => { + expectActionsInsideCard(await mountEmptyState(language, width)); + }, + ); + + it("keeps English actions on one row when they fit", async () => { + const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH); + const [addProject, importSession] = getAllByRole(card, "button"); + expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top); + }); +}); diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx index 3328ed2c5c..c9f6b7c568 100644 --- a/packages/app/src/components/sidebar/empty-states.tsx +++ b/packages/app/src/components/sidebar/empty-states.tsx @@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() { title={t("sidebar.filterEmpty.title")} description={t("sidebar.filterEmpty.description")} > - @@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({ title={t("sidebar.project.empty.title")} description={t("sidebar.project.empty.description")} > - - @@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({ }, actions: { flexDirection: "row", + flexWrap: "wrap", gap: theme.spacing[2], marginTop: theme.spacing[4], }, + action: { + maxWidth: "100%", + }, + actionText: { + flexShrink: 1, + }, title: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.test.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.test.ts new file mode 100644 index 0000000000..acd36cd808 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; +import { + selectSidebarGroupVisibility, + type SidebarGroupItem, + type SidebarGroupVisibilityPolicy, +} from "./sidebar-group-visibility"; + +interface Item extends SidebarGroupItem { + id: number; + statusBucket: SidebarStateBucket; +} + +const serverId = "host-a"; + +function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] { + return statuses.map((statusBucket, index) => ({ + id: index + 1, + serverId: rowServerId, + workspaceId: `workspace-${index + 1}`, + statusBucket, + })); +} + +const workspacePolicy: SidebarGroupVisibilityPolicy = { + kind: "workspaces", + getStatusBucket: (item) => item.statusBucket, +}; + +function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection { + return { serverId: item.serverId, workspaceId: item.workspaceId }; +} + +describe("selectSidebarGroupVisibility", () => { + it("leads with a hidden open row while keeping the baseline order", () => { + const items = rows(Array(9).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[8]!), + }); + + expect(result.leadingItem).toBe(items[8]); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); + + it("leaves selected rows in place when they are already in the baseline", () => { + const items = rows([...Array(8).fill("done"), "running", "done"]); + const expected = [...items.slice(0, 7), items[8]]; + + for (const selectedIndex of [2, 8]) { + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[selectedIndex]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(expected); + expect(result.canToggle).toBe(true); + } + }); + + it("shows the whole group without a toggle when the open row is the only hidden row", () => { + const items = rows(Array(8).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[7]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items); + expect(result.canToggle).toBe(false); + }); + + it("keeps expanded groups in their original order", () => { + const items = rows(Array(9).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: true, + policy: workspacePolicy, + selection: selectionFor(items[8]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items); + expect(result.canToggle).toBe(true); + }); + + it("applies the twenty-row limit to pinned groups", () => { + const items = rows(Array(22).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: { kind: "pinned" }, + selection: selectionFor(items[21]!), + }); + + expect(result.leadingItem).toBe(items[21]); + expect(result.visibleItems).toEqual(items.slice(0, 20)); + expect(result.canToggle).toBe(true); + }); + + it("preserves the current limit when selection is null", () => { + const items = rows(Array(8).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: null, + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); + + it("matches the open row by server and workspace identity", () => { + const items = rows(Array(9).fill("done"), "host-b"); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: { serverId: serverId, workspaceId: items[8]!.workspaceId }, + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts new file mode 100644 index 0000000000..f109f0114a --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -0,0 +1,84 @@ +import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; + +export interface SidebarGroupItem { + serverId: string; + workspaceId: string; +} + +export interface SidebarGroupVisibilityInput { + items: readonly T[]; + expanded: boolean; + policy: SidebarGroupVisibilityPolicy; + /** The workspace the group's rows highlight as selected. Null when no row can be selected. */ + selection: ActiveWorkspaceSelection | null; +} + +export interface SidebarGroupVisibility { + /** + * The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`, + * out of group order, outside any drag list. Never also present in `visibleItems`. + */ + leadingItem: T | null; + visibleItems: T[]; + canToggle: boolean; +} + +export type SidebarGroupVisibilityPolicy = + | { kind: "pinned" } + | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; + +// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the +// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders +// no row, so it counts as Done and claims no slot past the limit. +export function createWorkspaceVisibilityPolicy( + entriesByKey: ReadonlyMap, +): SidebarGroupVisibilityPolicy { + return { + kind: "workspaces", + getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done", + }; +} + +function isOpenWorkspace( + item: SidebarGroupItem, + selection: ActiveWorkspaceSelection | null, +): boolean { + return ( + selection !== null && + item.serverId === selection.serverId && + item.workspaceId === selection.workspaceId + ); +} + +export function selectSidebarGroupVisibility( + input: SidebarGroupVisibilityInput, +): SidebarGroupVisibility { + const { items, expanded, policy, selection } = input; + const limit = policy.kind === "pinned" ? 20 : 7; + const baseline: T[] = []; + let leadingItem: T | null = null; + let canToggle = false; + + items.forEach((item, index) => { + const inBaseline = + index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"); + if (inBaseline) { + baseline.push(item); + } else if (leadingItem === null && isOpenWorkspace(item, selection)) { + leadingItem = item; + } else { + canToggle = true; + } + }); + + if (expanded || !canToggle) { + return { leadingItem: null, visibleItems: items.slice(), canToggle }; + } + + return { + leadingItem, + visibleItems: baseline, + canToggle, + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index ec834aee19..cfa69428f8 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -134,7 +134,7 @@ export function SidebarModelProvider({ visibleWorkspaceKeys.has(workspace.workspaceKey), ); return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) - ? [{ ...project, workspaces }] + ? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }] : []; }); } diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index 425280207e..0f7e8c24ca 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -1,8 +1,9 @@ import { router, usePathname } from "expo-router"; import { CalendarClock, History, Plus, Search } from "lucide-react-native"; -import { memo, useCallback, useMemo, useRef, type ComponentType } from "react"; +import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { PluginSidebarItem } from "@/plugins/sidebar-items"; @@ -30,37 +31,49 @@ interface SidebarNavRowProps { interface SidebarNavRowsProps extends SidebarNavRowProps { /** Style for the group wrapper, which the sidebar owns. */ style?: StyleProp; + /** Sits to the right of the first row, which shrinks to make room. */ + firstRowTrailing?: ReactNode; } /** * Top-level sidebar navigation, ordered and filtered by the user's * `sidebarNavItems` preference. Renders nothing — not even the bordered group - * wrapper — when every item is hidden. + * wrapper — when every item is hidden and there is no `firstRowTrailing`. */ -export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) { +export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) { const { items } = useSidebarNavItems("header"); const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]); const groupRef = useRef(null); - if (visibleItems.length === 0) return null; + if (visibleItems.length === 0 && !firstRowTrailing) return null; + + const [firstRow, ...restRows] = visibleItems.map((item) => { + if (item.kind === "plugin") { + return ( + + ); + } + const Row = BUILTIN_ROWS[item.id]; + return ; + }); return ( - {visibleItems.map((item) => { - if (item.kind === "plugin") { - return ( - - ); - } - const Row = BUILTIN_ROWS[item.id]; - return ; - })} + {firstRowTrailing ? ( + + {firstRow} + {firstRowTrailing} + + ) : ( + firstRow + )} + {restRows} ); } @@ -132,14 +145,13 @@ function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) { ); } -function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) { +function SidebarSearchRow() { const { t } = useTranslation(); const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search")); const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const handlePress = useCallback(() => { - onBeforeNavigate?.(); setCommandCenterOpen(true); - }, [onBeforeNavigate, setCommandCenterOpen]); + }, [setCommandCenterOpen]); return ( state.collapsedWorkspaceGroupKeys, ); @@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({ (state) => state.togglePinnedCollapsed, ); const { + leadingItem: leadingPinnedWorkspace, visibleItems: visiblePinnedWorkspaces, expanded: pinnedWorkspacesExpanded, canToggle: canTogglePinnedWorkspaces, toggleExpanded: togglePinnedWorkspacesExpanded, - } = useLimitedSidebarGroup(pinnedWorkspaces); + } = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection }); const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : EMPTY_SHORTCUT_INDEX; - const renderPinnedWorkspace = useCallback( - ({ - item: workspace, - drag, - isActive, - dragHandleProps, - }: DraggableRenderItemInfo) => ( + const renderPinnedWorkspaceRow = useCallback( + ( + workspace: SidebarWorkspaceEntry, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => ( ), [ @@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({ supportsPinningByServerId, ], ); + const renderPinnedWorkspace = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => + renderPinnedWorkspaceRow(workspace, { + drag, + isDragging: isActive, + dragHandleProps, + }), + [renderPinnedWorkspaceRow], + ); const content = ( <> {pinnedWorkspaces.length > 0 ? ( @@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({ /> {pinnedCollapsed ? null : ( <> + {leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null} workspace.statusBucket, +}; + function StatusGroupRows({ group, collapsed, @@ -340,12 +365,37 @@ function StatusGroupRows({ supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { + leadingItem: leadingWorkspace, visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(group.rows); + } = useLimitedSidebarGroup({ + items: group.rows, + selection: activeWorkspaceSelection, + policy: STATUS_GROUP_VISIBILITY, + }); + + function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) { + return ( + + ); + } return ( @@ -355,22 +405,8 @@ function StatusGroupRows({ style={styles.statusWorkspaceListContainer} testID={`sidebar-status-group-rows-${group.key}`} > - {visibleWorkspaces.map((workspace) => ( - - ))} + {leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null} + {visibleWorkspaces.map(renderWorkspaceRow)} {canToggleWorkspaces ? ( { if (!workspace.serverId) return; - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( ; const unpinLeadingIcon = ; +const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderTriggerIcon({ hovered }: { hovered?: boolean }) { - return ( - - ); + return ; } export interface SidebarWorkspaceMenuProps { @@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere const styles = StyleSheet.create((theme) => ({ trigger: { - padding: 2, + // Larger on compact so the kebab is an easy tap target. + width: { xs: 32, md: 18 }, + height: { xs: 32, md: 18 }, + alignItems: "center", + justifyContent: "center", borderRadius: 4, marginLeft: 2, + // Center the compact box on the 20px title line it is anchored to. + marginTop: { xs: -6, md: 0 }, // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but // pull the painted dots through that unused view-box space onto the trailing-content rail. - marginRight: -7, + marginRight: { xs: -14, md: -7 }, }, triggerHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index c5f8a278bd..2488e7956e 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -41,8 +41,6 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; -function noop() {} - interface SidebarWorkspaceRowProps { workspace: SidebarWorkspaceEntry; selected: boolean; @@ -239,7 +237,7 @@ function WorkspaceRowBody({ const trailing = useSidebarWorkspaceTrailing(); const draggable = Boolean(drag); const interaction = useLongPressDragInteraction({ - drag: drag ?? noop, + drag, menuController: null, }); const { diff --git a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts index 5bf10ecec6..c7b77bfc16 100644 --- a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts +++ b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts @@ -1,15 +1,34 @@ import { useCallback, useMemo, useState } from "react"; +import { + selectSidebarGroupVisibility, + type SidebarGroupItem, + type SidebarGroupVisibility, + type SidebarGroupVisibilityPolicy, +} from "./sidebar-group-visibility"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; -const INITIAL_VISIBLE_ITEMS = 20; +const PINNED_POLICY = { kind: "pinned" } as const; -export function useLimitedSidebarGroup(items: readonly T[]) { +interface LimitedSidebarGroupInput { + items: readonly T[]; + selection: ActiveWorkspaceSelection | null; + policy?: SidebarGroupVisibilityPolicy; +} + +export function useLimitedSidebarGroup({ + items, + selection, + policy = PINNED_POLICY, +}: LimitedSidebarGroupInput): SidebarGroupVisibility & { + expanded: boolean; + toggleExpanded: () => void; +} { const [expanded, setExpanded] = useState(false); - const visibleItems = useMemo( - () => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)), - [expanded, items], + const visibility = useMemo( + () => selectSidebarGroupVisibility({ items, expanded, policy, selection }), + [expanded, items, policy, selection], ); - const canToggle = items.length > INITIAL_VISIBLE_ITEMS; const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); - return { visibleItems, expanded, canToggle, toggleExpanded }; + return { ...visibility, expanded, toggleExpanded }; } diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index 8e3a508b3b..f3de17b230 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -1,12 +1,12 @@ import { useCallback, useEffect, useRef } from "react"; -import { Platform, StatusBar, type GestureResponderEvent } from "react-native"; +import type { GestureResponderEvent } from "react-native"; import * as Haptics from "expo-haptics"; import { isWeb as platformIsWeb } from "@/constants/platform"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import type { useContextMenu } from "@/components/ui/context-menu"; export function useLongPressDragInteraction(input: { - drag: () => void; + drag?: () => void; menuController: ReturnType | null; }) { const { drag, menuController } = input; @@ -36,10 +36,9 @@ export function useLongPressDragInteraction(input: { if (!menuController || !touchStartRef.current) { return; } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; menuController.setAnchorRect({ x: touchStartRef.current.x, - y: touchStartRef.current.y + statusBarHeight, + y: touchStartRef.current.y, width: 0, height: 0, }); @@ -60,7 +59,7 @@ export function useLongPressDragInteraction(input: { const armTimers = useCallback(() => { clearTimers(); - if (platformIsWeb) { + if (platformIsWeb || !drag) { return; } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 9705f3be15..278317243a 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,5 +1,7 @@ +import { useWorkspaceTabDrag } from "./workspace-tab-drag-context"; import { Fragment, + memo, useCallback, useEffect, useMemo, @@ -10,35 +12,14 @@ import { type SetStateAction, } from "react"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { - DndContext, - DragOverlay, - KeyboardSensor, - PointerSensor, - closestCenter, - pointerWithin, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragMoveEvent, - type DragOverEvent, - type DragStartEvent, -} from "@dnd-kit/core"; -import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; -import { View, Text, type LayoutChangeEvent } from "react-native"; +import { View, type LayoutChangeEvent } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, type SharedValue, } from "react-native-reanimated"; -import { useTranslation } from "react-i18next"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; -import { - resolveExplorerSidebarDockSizes, - resolveExplorerSidebarWidth, -} from "@/components/explorer-sidebar-layout"; import { RetainedPanel } from "@/components/retained-panel"; import { hasMultipleVisiblePanes, @@ -47,55 +28,39 @@ import { } from "@/components/split-container-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { - removeWindowChromeCorner, WindowChromeRegion, WindowChromeSafeArea, useWindowChromeCorners, + removeWindowChromeCorner, type WindowChromeCorners, } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { - computeTabDropPreview, - type TabDropPreview, -} from "@/components/split-container-tab-drop-preview"; -import { - SplitDropZone, - resolveSplitDropPosition, - type SplitDropZoneHover, -} from "@/components/split-drop-zone"; -import { - deriveWorkspacePaneState, - getWorkspacePaneDescriptors, -} from "@/screens/workspace/workspace-pane-state"; +import { type TabDropPreview } from "@/components/split-container-tab-drop-preview"; +import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone"; +import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; -import { - WorkspaceTabPresentationResolver, - WorkspaceTabIcon, -} from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { + collectAllPanes, createDefaultLayout, - findPaneById, + selectExplorerSidebarPaneId, useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; -import { RenderProfile } from "@/utils/render-profiler"; +import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler"; import { isNative } from "@/constants/platform"; -import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; interface SplitContainerProps { layout: WorkspaceLayout; renderMainHeader?: () => ReactNode; - renderExplorerSidebarHeaderAction?: () => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -138,41 +103,6 @@ interface SplitContainerProps { onExitFocusMode: () => void; } -interface WorkspaceTabDragData { - kind: "workspace-tab"; - paneId: string; - tabId: string; -} - -interface SplitPaneDropData { - kind: "split-pane-drop"; - paneId: string; -} - -const EMPTY_SPLIT_NODES: SplitNode[] = []; -const EMPTY_SPLIT_SIZES: number[] = []; -const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar"; - -function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { - return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; -} - -function isSplitPaneDropData(data: unknown): data is SplitPaneDropData { - return ( - typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop" - ); -} - -function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined { - return isWorkspaceTabDragData(data) ? data : undefined; -} - -function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined { - if (isWorkspaceTabDragData(data)) return data; - if (isSplitPaneDropData(data)) return data; - return undefined; -} - interface SplitNodeViewProps extends Omit< SplitContainerProps, "layout" | "onMoveTabToPane" | "onSelectTabInPane" @@ -210,105 +140,12 @@ interface SplitPaneViewProps extends Omit< tabDropPreview: TabDropPreview | null; } -interface DragMoveRects { - translatedRect: { left: number; top: number; width: number; height: number }; - overRect: { left: number; top: number; width: number; height: number }; -} - -function resolveDragMoveRects( - event: Pick | Pick, -): DragMoveRects | null { - const translatedRect = event.active.rect.current.translated; - const overRect = event.over?.rect; - if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { - return null; - } - return { translatedRect, overRect }; -} - -function computeTabOverDropPreview(input: { - activeData: WorkspaceTabDragData; - overData: WorkspaceTabDragData; - rects: DragMoveRects; - panesById: Map; - uiTabs: WorkspaceTab[]; -}): TabDropPreview | null { - const { activeData, overData, rects, panesById, uiTabs } = input; - const targetPane = panesById.get(overData.paneId) ?? null; - if (!targetPane) { - return null; - } - const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); - return computeTabDropPreview({ - activePaneId: activeData.paneId, - activeTabId: activeData.tabId, - overPaneId: overData.paneId, - overTabId: overData.tabId, - targetTabs, - activeRect: { - left: rects.translatedRect.left, - width: rects.translatedRect.width, - }, - overRect: { - left: rects.overRect.left, - width: rects.overRect.width, - }, - }); -} - -function computePaneOverDropPreview(input: { - overData: SplitPaneDropData; - rects: DragMoveRects; -}): SplitDropZoneHover | null { - const { overData, rects } = input; - const centerX = rects.translatedRect.left + rects.translatedRect.width / 2; - const centerY = rects.translatedRect.top + rects.translatedRect.height / 2; - const relativeX = centerX - rects.overRect.left; - const relativeY = centerY - rects.overRect.top; - if ( - Number.isNaN(relativeX) || - Number.isNaN(relativeY) || - relativeX < 0 || - relativeX > rects.overRect.width || - relativeY < 0 || - relativeY > rects.overRect.height - ) { - return null; - } - return { - paneId: overData.paneId, - position: resolveSplitDropPosition({ - width: rects.overRect.width, - height: rects.overRect.height, - x: relativeX, - y: relativeY, - }), - }; -} - -const dropCollisionDetection: CollisionDetection = (args) => { - const pointerHits = pointerWithin(args); - const tabHits = pointerHits.filter( - (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab", - ); - if (tabHits.length > 0) { - return tabHits; - } - - const paneHits = pointerHits.filter( - (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop", - ); - if (paneHits.length > 0) { - return paneHits; - } - - return closestCenter(args); -}; +const EMPTY_SPLIT_NODES: SplitNode[] = []; +const EMPTY_SPLIT_SIZES: number[] = []; export function SplitContainer({ layout, renderMainHeader, - renderExplorerSidebarHeaderAction, workspaceKey, normalizedServerId, normalizedWorkspaceId, @@ -333,8 +170,6 @@ export function SplitContainer({ onFocusPane, onSplitPane, onSplitPaneEmpty, - onMoveTabToPane, - onSelectTabInPane, onResizeSplit, onReorderTabsInPane, focusModeEnabled, @@ -342,35 +177,17 @@ export function SplitContainer({ }: SplitContainerProps) { const inheritedWindowChromeCorners = useWindowChromeCorners(); const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; - const [activeDragTabId, setActiveDragTabId] = useState(null); - const [dropPreview, setDropPreview] = useState(null); - const [tabDropPreview, setTabDropPreview] = useState(null); + const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag(); const [maximizedPane, setMaximizedPane] = useState<{ workspaceKey: string; paneId: string; } | null>(null); const maximizedPaneId = maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; - const explorerSidebarPaneId = useWorkspaceLayoutStore( - (state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, + const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => + selectExplorerSidebarPaneId(state, workspaceKey), ); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); - const explorerSidebarPane = useMemo( - () => findPaneById(layout.root, explorerSidebarPaneId), - [layout.root, explorerSidebarPaneId], - ); const mainRoot = useMemo( () => removePaneFromSplitTree(layout.root, explorerSidebarPaneId), [layout.root, explorerSidebarPaneId], @@ -406,434 +223,68 @@ export function SplitContainer({ }), [focusModeEnabled, layout.focusedPaneId, mainRoot], ); - const storedExplorerSidebarWidth = useWorkspaceLayoutStore( - (state) => state.explorerSidebarWidthByWorkspace[workspaceKey], - ); - const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); - const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0); - const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState( - null, - ); - const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth; - const explorerSidebarWidth = resolveExplorerSidebarWidth({ - requestedWidth: requestedExplorerSidebarWidth, - containerWidth: workspaceShellWidth, - }); - const explorerSidebarDockSizes = useMemo( - () => - resolveExplorerSidebarDockSizes({ - requestedWidth: requestedExplorerSidebarWidth, - containerWidth: workspaceShellWidth, - }), - [requestedExplorerSidebarWidth, workspaceShellWidth], - ); - const renderExplorerSidebarDock = Boolean( - !focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true, - ); - const mainColumnWindowChromeCorners = renderExplorerSidebarDock - ? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right") - : inheritedWindowChromeCorners; + const explorerPane = explorerSidebarPaneId + ? collectAllPanes(layout.root).find((pane) => pane.id === explorerSidebarPaneId) + : null; + const mainColumnWindowChromeCorners = + !focusModeEnabled && explorerPane && !explorerPane.hidden + ? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right") + : inheritedWindowChromeCorners; const mainColumnStyle = styles.mainColumn; - const explorerSidebarDockStyle = useMemo( - () => [styles.explorerSidebarDock, { width: explorerSidebarWidth }], - [explorerSidebarWidth], - ); - const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => { - const nextWidth = event.nativeEvent.layout.width; - setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth)); - }, []); - const previewExplorerSidebarResize = useCallback( - (_groupId: string, sizes: number[]) => { - const nextRatio = sizes[1]; - if (nextRatio !== undefined) { - setPreviewExplorerSidebarWidth( - resolveExplorerSidebarWidth({ - requestedWidth: nextRatio * workspaceShellWidth, - containerWidth: workspaceShellWidth, - }), - ); - } - }, - [workspaceShellWidth], - ); - const commitExplorerSidebarResize = useCallback( - (_groupId: string, sizes: number[]) => { - setPreviewExplorerSidebarWidth(null); - const nextRatio = sizes[1]; - if (nextRatio !== undefined) { - resizeExplorerSidebar( - workspaceKey, - resolveExplorerSidebarWidth({ - requestedWidth: nextRatio * workspaceShellWidth, - containerWidth: workspaceShellWidth, - }), - ); - } - }, - [resizeExplorerSidebar, workspaceKey, workspaceShellWidth], - ); const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); - const handleDragStart = useCallback((event: DragStartEvent) => { - const data = asWorkspaceTabDragData(event.active.data.current); - if (!data) { - setActiveDragTabId(null); - setDropPreview(null); - setTabDropPreview(null); - return; - } - setActiveDragTabId(data.tabId); - }, []); - - const handleDragCancel = useCallback(() => { - setActiveDragTabId(null); - setDropPreview(null); - setTabDropPreview(null); - }, []); - - const updateDropPreview = useCallback( - (event: Pick | Pick) => { - const activeData = asWorkspaceTabDragData(event.active.data.current); - const overData = asDragOverData(event.over?.data.current); - - if (activeData?.kind !== "workspace-tab") { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - const rects = resolveDragMoveRects(event); - if (!rects) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null; - const destinationPaneId = - overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop" - ? overData.paneId - : null; - const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main"; - if ( - !activeTab || - !panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost) - ) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - if (overData?.kind === "workspace-tab") { - const preview = computeTabOverDropPreview({ - activeData, - overData, - rects, - panesById, - uiTabs, - }); - setDropPreview(null); - setTabDropPreview(preview); - return; - } - - setTabDropPreview(null); - if (overData?.kind !== "split-pane-drop") { - setDropPreview(null); - return; - } - - setDropPreview(computePaneOverDropPreview({ overData, rects })); - }, - [normalizedServerId, panesById, explorerSidebarPaneId, uiTabs], - ); - - const applyTabDropEnd = useCallback( - (input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => { - const { activeData, overData } = input; - const sourcePane = panesById.get(activeData.paneId) ?? null; - const targetPane = panesById.get(overData.paneId) ?? null; - if (!sourcePane || !targetPane) { - return; - } - - const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); - const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); - const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); - const resolvedTabDropPreview = - tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null; - if (sourceIndex < 0 || !resolvedTabDropPreview) { - return; - } - - if (activeData.paneId === overData.paneId) { - if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { - const nextTabs = arrayMove( - sourceTabs, - sourceIndex, - resolvedTabDropPreview.insertionIndex, - ); - onReorderTabsInPane( - activeData.paneId, - nextTabs.map((tab) => tab.tabId), - ); - } - return; - } - - const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); - nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId); - onMoveTabToPane(activeData.tabId, overData.paneId); - onReorderTabsInPane(overData.paneId, nextTargetTabIds); - }, - [onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs], - ); - - const applyPaneDropEnd = useCallback( - (input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => { - const { activeData, overData } = input; - if (dropPreview?.paneId !== overData.paneId) { - return; - } - if (dropPreview.position === "center") { - if (activeData.paneId !== overData.paneId) { - onMoveTabToPane(activeData.tabId, overData.paneId); - } - return; - } - onSplitPane({ - tabId: activeData.tabId, - targetPaneId: overData.paneId, - position: dropPreview.position, - }); - }, - [dropPreview, onMoveTabToPane, onSplitPane], - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const activeData = asWorkspaceTabDragData(event.active.data.current); - const overData = asDragOverData(event.over?.data.current); - - setActiveDragTabId(null); - - if (activeData?.kind === "workspace-tab" && event.over) { - if (overData?.kind === "workspace-tab") { - applyTabDropEnd({ activeData, overData }); - } else if (overData?.kind === "split-pane-drop") { - applyPaneDropEnd({ activeData, overData }); - } - } - - setDropPreview(null); - setTabDropPreview(null); - }, - [applyTabDropEnd, applyPaneDropEnd], - ); - return ( - - - - - {renderMainHeader?.()} - {splitRoot.usesFallbackStrip && } - {renderRoot ? ( - - ) : null} - - - {renderExplorerSidebarDock && explorerSidebarPane ? ( - <> - + + + {renderMainHeader?.()} + {splitRoot.usesFallbackStrip && } + {renderRoot ? ( + - - - - - ) : null} - - - {activeDragTabId ? ( - - ) : null} - - - - ); -} - -function DragOverlayTabChip({ - tabId, - uiTabs, - normalizedServerId, - normalizedWorkspaceId, -}: { - tabId: string; - uiTabs: WorkspaceTab[]; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const tab = uiTabs.find((t) => t.tabId === tabId); - const descriptor = useMemo( - () => - tab - ? { - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - } - : null, - [tab], - ); - if (!descriptor) { - return null; - } - return ( - - ); -} - -function DragOverlayTabChipInner({ - tab, - normalizedServerId, - normalizedWorkspaceId, -}: { - tab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const { t } = useTranslation(); - const { theme } = useUnistyles(); - - const chipStyle = useMemo( - () => [ - styles.dragOverlayChip, - { - backgroundColor: theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - ], - [theme.colors.surface1, theme.colors.borderAccent], - ); - const chipLabelStyle = useMemo( - () => [styles.dragOverlayLabel, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - - return ( - - {(presentation) => { - const label = - presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; - - return ( - - - - {label} - + ) : null} - ); - }} - + + + ); } @@ -1121,7 +572,7 @@ function SplitNodeView({ ); } -function SplitPaneView({ +const SplitPaneView = memo(function SplitPaneView({ pane, uiTabs, isFocused, @@ -1158,6 +609,7 @@ function SplitPaneView({ focusModeEnabled, onExitFocusMode, }: SplitPaneViewProps) { + recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]); const paneRef = useRef(null); const stableOnFocusPane = useStableEvent(onFocusPane); const paneState = useMemo( @@ -1312,22 +764,7 @@ function SplitPaneView({ ); -} - -function collectPanesById(node: SplitNode): Map { - const next = new Map(); - function visit(current: SplitNode) { - if (current.kind === "pane") { - next.set(current.pane.id, current.pane); - return; - } - for (const child of current.group.children) { - visit(child); - } - } - visit(node); - return next; -} +}); function getNodeKey(node: SplitNode): string { if (node.kind === "pane") { @@ -1393,12 +830,6 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, minHeight: 0, }, - explorerSidebarDock: { - flexShrink: 0, - minWidth: 240, - minHeight: 0, - backgroundColor: theme.colors.surfaceSidebar, - }, group: { flex: 1, minWidth: 0, @@ -1433,18 +864,4 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, minHeight: 0, }, - dragOverlayChip: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - maxWidth: 200, - }, - dragOverlayLabel: { - fontSize: theme.fontSize.base, - flexShrink: 1, - }, })); diff --git a/packages/app/src/components/split-drop-zone.tsx b/packages/app/src/components/split-drop-zone.tsx index b761f6b937..266c766cec 100644 --- a/packages/app/src/components/split-drop-zone.tsx +++ b/packages/app/src/components/split-drop-zone.tsx @@ -147,7 +147,7 @@ function getPreviewFrameStyle(position: SplitDropZonePosition) { const styles = StyleSheet.create((theme) => ({ overlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, zIndex: 40, }, previewOverlay: { diff --git a/packages/app/src/components/status-icon.tsx b/packages/app/src/components/status-icon.tsx new file mode 100644 index 0000000000..afa7f4b078 --- /dev/null +++ b/packages/app/src/components/status-icon.tsx @@ -0,0 +1,119 @@ +import { useMemo, type ReactElement, type ReactNode } from "react"; +import { View } from "react-native"; +import { CircleAlert } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { StatusRing } from "@/components/status-ring"; +import { getStatusRingOffset } from "@/components/status-ring/geometry"; +import { + STATUS_INDICATOR_ALERT_SIZE, + STATUS_INDICATOR_DOT_SIZE, +} from "@/utils/status-indicator-geometry"; +import type { Theme } from "@/styles/theme"; + +interface StatusIconProps { + children: ReactNode; + statusBucket: SidebarStateBucket | null; + size?: number; + statusDotBorderColor?: string; + backdrop: SurfaceBackdrop; +} +const DEFAULT_STATUS_DOT_OFFSET = -2; +const STATUS_ALERT_OFFSET = -3; +const ThemedCircleAlert = withUnistyles(CircleAlert); +const needsInputAlertMapping = (theme: Theme) => ({ + color: theme.colors.surface0, + fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, +}); + +export function StatusIcon({ + children, + statusBucket, + size = 14, + statusDotBorderColor, + backdrop, +}: StatusIconProps): ReactElement { + const bucket = statusBucket; + const isRunning = bucket === "running"; + let statusDotColor: string | undefined; + if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; + else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; + const showNeedsInputAlert = bucket === "needs_input"; + const agentIconWrapperStyle = useMemo( + () => [styles.agentIconWrapper, { width: size, height: size }], + [size], + ); + const statusDotStyle = useMemo( + () => [ + styles.statusDot, + { + backgroundColor: statusDotColor, + borderColor: statusDotBorderColor ?? styles[backdrop].borderColor, + }, + ], + [statusDotColor, statusDotBorderColor, backdrop], + ); + + return ( + + {children} + {isRunning ? ( + + + + ) : null} + {statusDotColor ? : null} + {showNeedsInputAlert ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + agentIconWrapper: { + position: "relative", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + statusDot: { + position: "absolute", + right: DEFAULT_STATUS_DOT_OFFSET, + bottom: DEFAULT_STATUS_DOT_OFFSET, + width: STATUS_INDICATOR_DOT_SIZE, + height: STATUS_INDICATOR_DOT_SIZE, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + statusRing: { + position: "absolute", + right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + }, + surface0: { borderColor: theme.colors.surface0 }, + surface1: { borderColor: theme.colors.surface1 }, + surface2: { borderColor: theme.colors.surface2 }, + surfaceSidebar: { borderColor: theme.colors.surfaceSidebar }, + surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover }, + surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected }, + statusAlertOverlay: { + position: "absolute", + right: STATUS_ALERT_OFFSET, + bottom: STATUS_ALERT_OFFSET, + }, + statusDotFailed: { + color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, + }, + statusDotAttention: { + color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, + }, +})); diff --git a/packages/app/src/components/status-ring/clock.ts b/packages/app/src/components/status-ring/clock.ts index 7fd887e42e..430edb6e83 100644 --- a/packages/app/src/components/status-ring/clock.ts +++ b/packages/app/src/components/status-ring/clock.ts @@ -1,11 +1,13 @@ -import { useLayoutEffect } from "react"; +import { useLayoutEffect, useState } from "react"; import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated"; import { scheduleOnUI } from "react-native-worklets"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { getStatusRingRotation } from "@/components/status-ring/geometry"; const sharedRotation = makeMutable(getStatusRingRotation(Date.now())); const activeRingCount = makeMutable(0); const clockRunning = makeMutable(false); +let nextListenerId = 1; function advanceSharedRotation(): void { "worklet"; @@ -18,13 +20,21 @@ function advanceSharedRotation(): void { requestAnimationFrame(advanceSharedRotation); } -function registerStatusRing(registered: SharedValue): void { +function registerStatusRing( + rotation: SharedValue, + registered: SharedValue, + listenerId: number, +): void { "worklet"; if (registered.value) { return; } registered.value = true; + rotation.value = getStatusRingRotation(Date.now()); + sharedRotation.addListener(listenerId, (value) => { + rotation.value = value; + }); activeRingCount.value += 1; if (!clockRunning.value) { @@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue): void { } } -function unregisterStatusRing(registered: SharedValue): void { +function unregisterStatusRing(registered: SharedValue, listenerId: number): void { "worklet"; if (!registered.value) { return; } registered.value = false; + sharedRotation.removeListener(listenerId); activeRingCount.value -= 1; } export function useStatusRingRotation(): SharedValue { + const active = useRetainedPanelActive(); + // Hidden retained views must not subscribe their animated styles to the shared clock. + const rotation = useSharedValue(getStatusRingRotation(Date.now())); const registered = useSharedValue(false); + const [listenerId] = useState(() => nextListenerId++); useLayoutEffect(() => { - scheduleOnUI(registerStatusRing, registered); + if (!active) return; + scheduleOnUI(registerStatusRing, rotation, registered, listenerId); return () => { - scheduleOnUI(unregisterStatusRing, registered); + scheduleOnUI(unregisterStatusRing, registered, listenerId); }; - }, [registered]); + }, [active, listenerId, registered, rotation]); - return sharedRotation; + return rotation; } diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx index 26bba48aad..bf5f86eedb 100644 --- a/packages/app/src/components/terminal-copy-paste-actions.tsx +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; export interface TerminalPasteActionProps { - hasClipboardText: boolean; + /** Clipboard holds text or an image, so a paste attempt can do something. */ + hasClipboardContent: boolean; onPaste: () => void; } -export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) { +export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) { return ( diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 3575e21500..b4e3dd93e1 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -4,7 +4,6 @@ import type { } from "../terminal/runtime/terminal-emulator-runtime"; import type { Ref } from "react"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; import type { @@ -12,14 +11,15 @@ import type { TerminalLocalFileLinkTarget, } from "../terminal/local-links/terminal-local-link-provider"; import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; +import type { TerminalFile } from "../terminal/file-paste"; +import type { TerminalCharacterWidths } from "../terminal/character-widths"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; export interface TerminalEmulatorHandle { find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; + replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void; paste: (text: string) => void; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -33,6 +33,7 @@ export interface TerminalEmulatorProps { ref: Ref; streamKey: string; supportsTerminalInputModeReplay: boolean; + characterWidths: TerminalCharacterWidths; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; @@ -43,7 +44,6 @@ export interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; @@ -75,4 +75,5 @@ export interface TerminalEmulatorProps { pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; + onFilePaste?: (file: Promise) => void; } diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index 311528bdac..ad71316f3c 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -1,19 +1,17 @@ +import Svg, { Path } from "react-native-svg"; import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { Keyboard, type LayoutChangeEvent, PanResponder, - type PanResponderGestureState, Pressable, StyleSheet, Text, View, - type GestureResponderEvent, type StyleProp, type ViewStyle, } from "react-native"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker, terminalInputModeStatesEqual, @@ -48,13 +46,10 @@ import { type TerminalSelectionViewport, } from "../terminal/native-renderer/terminal-selection"; import { - TERMINAL_GESTURE_LONG_PRESS_MS, - TERMINAL_GESTURE_TAP_TOLERANCE_PX, - advanceTerminalScrollGesture, - classifyTerminalGestureIntent, - resolveTerminalGestureReleaseAction, - type TerminalGestureIntent, -} from "../terminal/native-renderer/terminal-selection-gesture"; + createTerminalSelectionController, + terminalSelectionHandles, + TERMINAL_SELECTION_GRIP_SIZE, +} from "../terminal/native-renderer/terminal-selection-controller"; import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy"; import { forwardNativeTerminalKey, @@ -88,75 +83,8 @@ const DEFAULT_XTERM_THEME: ITheme = { foreground: "#e6e6e6", cursor: "#e6e6e6", }; -interface NativeTerminalPanState { - lastDy: number; - rowRemainder: number; - didScroll: boolean; - didNavigate: boolean; - movedBeyondTapTolerance: boolean; - intent: TerminalGestureIntent | null; -} - -interface NativeTerminalSelectionPress { - startX: number; - startY: number; - startedAt: number; - startedWithSelection: boolean; - status: "pressing" | "selecting"; -} - -function resolvePanResponderPoint( - press: NativeTerminalSelectionPress, - event: GestureResponderEvent, - gesture: PanResponderGestureState, -): { x: number; y: number } { - const eventDx = event.nativeEvent.locationX - press.startX; - const eventDy = event.nativeEvent.locationY - press.startY; - const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx; - const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy; - return { - x: press.startX + dx, - y: press.startY + dy, - }; -} - -function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize { - return { - rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS, - cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS, - }; -} - -function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState { - const visibleRows = viewport.grid.length; - const currentViewport = { - firstRow: viewport.firstRow, - lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1), - }; - return { - viewport, - scroll: { - mode: "following", - firstRow: viewport.firstRow, - visibleRows, - oldestRow: viewport.oldestRow, - newestRow: viewport.newestRow, - currentViewport, - bottomViewport: currentViewport, - }, - }; -} - -function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState { - return { - rows: state.grid.length, - cols: state.cols, - firstRow: 0, - oldestRow: 0, - newestRow: Math.max(0, state.grid.length - 1), - grid: state.grid, - cursor: state.cursor, - }; +function initialNativeTerminalSize(): TerminalMeasuredSize { + return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; } function NativeTerminalEmulator({ @@ -165,11 +93,11 @@ function NativeTerminalEmulator({ testId = "terminal-surface", xtermTheme = DEFAULT_XTERM_THEME, scrollbackLines, + characterWidths, fontFamily, fontSize, keyboardInset = 0, isKeyboardVisible = false, - initialSnapshot = null, onInput, onTerminalKey, onResize, @@ -181,20 +109,18 @@ function NativeTerminalEmulator({ resizeRequestToken = 0, }: TerminalEmulatorProps) { const terminalRef = useRef(null); - const mountInputRef = useRef({ streamKey, initialSnapshot }); + const mountInputRef = useRef({ streamKey }); if ( resolveNativeTerminalMountAction({ mountedStreamKey: mountInputRef.current.streamKey, nextStreamKey: streamKey, }) === "mount" ) { - mountInputRef.current = { streamKey, initialSnapshot }; + mountInputRef.current = { streamKey }; } const inputRef = useRef(null); - const terminalSizeRef = useRef(initialNativeTerminalSize(initialSnapshot)); - const resizePolicyRef = useRef( - createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)), - ); + const terminalSizeRef = useRef(initialNativeTerminalSize()); + const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize())); const activeResizeClaimTokenRef = useRef(0); const layoutRef = useRef(null); const metricsRef = useRef(null); @@ -202,29 +128,20 @@ function NativeTerminalEmulator({ const outputDrainRef = useRef(null); const screenModelRef = useRef(null); const selectionModelRef = useRef(createTerminalSelectionModel()); - const selectionLongPressTimeoutRef = useRef | null>(null); - const selectionPressRef = useRef(null); + const selectionControllerRef = useRef | null>(null); + const gestureStartRef = useRef({ x: 0, y: 0 }); + const [cellMetrics, setCellMetrics] = useState(null); const latestResolvedScreenRef = useRef(null); const paintFrameRef = useRef(null); const outputDecoderRef = useRef(new TextDecoder()); - const panStateRef = useRef({ - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }); const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); const lastInputModeStateRef = useRef({ ...inputModeTrackerRef.current.getState(), applicationCursorKeys: false, }); - const [terminalScreen, setTerminalScreen] = useState(() => { - return initialSnapshot - ? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot)) - : null; - }); + const [terminalScreen, setTerminalScreen] = useState(null); const [selectionRange, setSelectionRange] = useState(null); const selectionRangeRef = useRef(null); const hasNotifiedSelectionRef = useRef(false); @@ -288,6 +205,7 @@ function NativeTerminalEmulator({ }, []); const clearSelection = useCallback(() => { + selectionControllerRef.current?.cancel(); const snapshot = selectionModelRef.current.clear(); commitSelectionRange(snapshot.range); }, [commitSelectionRange]); @@ -298,20 +216,14 @@ function NativeTerminalEmulator({ const syncSelectionWithTerminal = useCallback( (terminal: NativeHeadlessTerminal) => { + const previous = selectionModelRef.current.getSnapshot().range; const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); + if (previous && !snapshot.range) selectionControllerRef.current?.cancel(); commitSelectionRange(snapshot.range); }, [commitSelectionRange], ); - const clearSelectionLongPressTimeout = useCallback(() => { - if (selectionLongPressTimeoutRef.current === null) { - return; - } - clearTimeout(selectionLongPressTimeoutRef.current); - selectionLongPressTimeoutRef.current = null; - }, []); - const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { return ( resolveMeasuredNativeTerminalSize({ @@ -361,10 +273,14 @@ function NativeTerminalEmulator({ (terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain => createNativeTerminalOutputDrain({ write: async (chunk) => { + inputModeTrackerRef.current.feed(chunk); await terminal.write(chunk); emitInputModeChange(); }, - reset: () => { + restoreSize: () => terminal.resize(terminalSizeRef.current), + reset: (size) => { + inputModeTrackerRef.current.reset(); + if (size) terminal.resize(size); terminal.reset(); emitInputModeChange(); }, @@ -415,11 +331,11 @@ function NativeTerminalEmulator({ return; } + const isFirstMeasuredSize = !hasNotifiedMeasuredSizeRef.current; hasNotifiedMeasuredSizeRef.current = true; terminalSizeRef.current = policyResult.measuredSize; if (policyResult.measuredSizeChanged) { - terminalRef.current?.resize(policyResult.measuredSize); - schedulePaint(); + outputDrainRef.current?.resize(); } callbacksRef.current.onResize?.({ rows: policyResult.measuredSize.rows, @@ -427,69 +343,62 @@ function NativeTerminalEmulator({ shouldClaim: policyResult.resizeClaim !== null, forceClaim: policyResult.resizeClaim?.force, }); - }, - [schedulePaint], - ); - - const resetTerminal = useCallback( - (snapshot: TerminalState | null) => { - outputDrainRef.current?.dispose(); - outputDrainRef.current = null; - terminalRef.current?.dispose(); - screenModelRef.current = null; - latestResolvedScreenRef.current = null; - clearSelection(); - const measuredSize = resolveMeasuredNativeTerminalSize({ - layout: layoutRef.current, - metrics: metricsRef.current, - }); - const size = - measuredSize ?? - (snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current); - terminalSizeRef.current = size; - resizePolicyRef.current = createTerminalResizePolicy(size); - outputDecoderRef.current.decode(); - const terminal = createNativeHeadlessTerminal({ - rows: size.rows, - cols: size.cols, - scrollbackLines, - }); - terminalRef.current = terminal; - screenModelRef.current = createNativeTerminalScreenModel({ terminal }); - const outputDrain = createOutputDrain(terminal); - outputDrainRef.current = outputDrain; - const screen = snapshot - ? screenStateFromViewport(viewportStateFromSnapshot(snapshot)) - : resolveTerminalScreen(terminal); - commitTerminalScreen(screen); - resetInputModeTracker(); - if (snapshot) { - outputDrain.restoreSnapshot(snapshot); + // Ready waits for the grid size, so the pane attaches with it and the daemon replays the + // terminal at that size instead of the PTY's. + if (isFirstMeasuredSize) { + callbacksRef.current.onRendererReadyChange?.({ + streamKey: mountInputRef.current.streamKey, + isReady: true, + }); } }, - [ - commitTerminalScreen, - createOutputDrain, - resetInputModeTracker, - resolveTerminalScreen, + [], + ); + + const resetTerminal = useCallback(() => { + outputDrainRef.current?.dispose(); + outputDrainRef.current = null; + terminalRef.current?.dispose(); + screenModelRef.current = null; + latestResolvedScreenRef.current = null; + clearSelection(); + const measuredSize = resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + }); + const size = measuredSize ?? terminalSizeRef.current; + terminalSizeRef.current = size; + resizePolicyRef.current = createTerminalResizePolicy(size); + outputDecoderRef.current.decode(); + const terminal = createNativeHeadlessTerminal({ + rows: size.rows, + cols: size.cols, scrollbackLines, - clearSelection, - ], - ); + characterWidths, + }); + terminalRef.current = terminal; + screenModelRef.current = createNativeTerminalScreenModel({ terminal }); + const outputDrain = createOutputDrain(terminal); + outputDrainRef.current = outputDrain; + const screen = resolveTerminalScreen(terminal); + commitTerminalScreen(screen); + resetInputModeTracker(); + }, [ + commitTerminalScreen, + createOutputDrain, + resetInputModeTracker, + resolveTerminalScreen, + scrollbackLines, + characterWidths, + clearSelection, + ]); - const enqueueOutputText = useCallback( - (text: string) => { - if (text.length === 0) { - return; - } - const inputModeUpdate = inputModeTrackerRef.current.feed(text); - if (inputModeUpdate.changed) { - emitInputModeChange(); - } - outputDrainRef.current?.enqueueText(text); - }, - [emitInputModeChange], - ); + const enqueueOutputText = useCallback((text: string) => { + if (text.length === 0) { + return; + } + outputDrainRef.current?.enqueueText(text); + }, []); const claimActiveTerminalSize = useCallback(() => { activeResizeClaimTokenRef.current += 1; @@ -512,27 +421,7 @@ function NativeTerminalEmulator({ const text = outputDecoderRef.current.decode(data, { stream: true }); enqueueOutputText(text); }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - outputDrainRef.current?.restoreText(text); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - if (!state) { - resetTerminal(null); - return; - } - clearSelection(); - const outputDrain = outputDrainRef.current; - if (!outputDrain) { - resetTerminal(state); - return; - } - outputDrain.restoreSnapshot(state); - }, + replay: (event) => outputDrainRef.current?.replay(event), paste: (text: string) => { if (text.length === 0) { return; @@ -563,7 +452,6 @@ function NativeTerminalEmulator({ screenModelRef.current?.reset(); clearSelection(); resetInputModeTracker(); - terminalRef.current?.reset(); schedulePaint(); }, claimSize: claimActiveTerminalSize, @@ -582,19 +470,17 @@ function NativeTerminalEmulator({ claimActiveTerminalSizeForAction, clearSelection, resetInputModeTracker, - resetTerminal, schedulePaint, ], ); useEffect(() => { hasNotifiedMeasuredSizeRef.current = false; - resetTerminal(mountInputRef.current.initialSnapshot); - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true }); + resetTerminal(); + emitMeasuredSize({ source: "measure" }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; + selectionControllerRef.current?.cancel(); if (paintFrameRef.current !== null) { cancelAnimationFrame(paintFrameRef.current); paintFrameRef.current = null; @@ -604,7 +490,7 @@ function NativeTerminalEmulator({ terminalRef.current?.dispose(); terminalRef.current = null; }; - }, [clearSelectionLongPressTimeout, resetTerminal, streamKey]); + }, [emitMeasuredSize, resetTerminal, streamKey]); useEffect(() => { if (focusRequestToken <= 0) return; @@ -661,6 +547,7 @@ function NativeTerminalEmulator({ const handleCellMetricsChange = useCallback( (metrics: TerminalGridCellMetrics) => { metricsRef.current = metrics; + setCellMetrics(metrics); emitMeasuredSize({ source: "measure" }); }, [emitMeasuredSize], @@ -733,14 +620,6 @@ function NativeTerminalEmulator({ if (!coordinate) { return; } - const existingPress = selectionPressRef.current; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: existingPress?.startedAt ?? Date.now(), - startedWithSelection: existingPress?.startedWithSelection ?? false, - status: "selecting", - }; const bounds = terminal.getBufferBounds(); const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); let snapshot = selectionModelRef.current.begin({ @@ -755,217 +634,61 @@ function NativeTerminalEmulator({ [commitSelectionRange, resolveSelectionCoordinate], ); - const updateSelectionAtPoint = useCallback( - (point: { x: number; y: number }) => { - const terminal = terminalRef.current; - if (!terminal || selectionPressRef.current?.status !== "selecting") { - return; - } - const coordinate = resolveSelectionCoordinate(point); - if (!coordinate) { - return; - } - const snapshot = selectionModelRef.current.update({ - coordinate, - bounds: terminal.getBufferBounds(), - }); - commitSelectionRange(snapshot.range); - }, - [commitSelectionRange, resolveSelectionCoordinate], - ); - - const handlePanResponderScroll = useCallback( - (currentDy: number) => { - const cellHeight = metricsRef.current?.cellHeight; - if (!cellHeight || cellHeight <= 0) { - return; - } - const panState = panStateRef.current; - const update = advanceTerminalScrollGesture({ - state: panState, - currentDy, - cellHeight, - }); - panState.lastDy = update.state.lastDy; - panState.rowRemainder = update.state.rowRemainder; - panState.didScroll = update.state.didScroll; - const { rowDelta } = update; - if (rowDelta === 0) { - return; - } - if (rowDelta > 0) { - applyScroll("up", rowDelta); - return; - } - applyScroll("down", Math.abs(rowDelta)); - }, - [applyScroll], + const selectionController = useMemo( + () => + createTerminalSelectionController({ + getGeometry: () => { + const viewport = resolveSelectionViewport(); + const metrics = metricsRef.current; + return viewport && metrics + ? { range: selectionRangeRef.current, viewport, metrics } + : null; + }, + onBeginHandle: (endpoint, cols) => + selectionModelRef.current.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + const terminal = terminalRef.current; + if (!terminal) return; + commitSelectionRange( + selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() }) + .range, + ); + }, + onLongPress: beginSelectionAtPoint, + onTap: focusTerminalFromTap, + onScroll: applyScroll, + schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number, + cancel: (timer) => clearTimeout(timer), + }), + [ + applyScroll, + beginSelectionAtPoint, + commitSelectionRange, + focusTerminalFromTap, + resolveSelectionViewport, + ], ); + selectionControllerRef.current = selectionController; const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, - onMoveShouldSetPanResponderCapture: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, onPanResponderGrant: (event) => { - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const point = { - x: event.nativeEvent.locationX, - y: event.nativeEvent.locationY, - }; - const pressStatus = selectionRangeRef.current ? "selecting" : "pressing"; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: Date.now(), - startedWithSelection: pressStatus === "selecting", - status: pressStatus, - }; - clearSelectionLongPressTimeout(); - if (pressStatus === "selecting") { - return; - } - selectionLongPressTimeoutRef.current = setTimeout(() => { - selectionLongPressTimeoutRef.current = null; - const press = selectionPressRef.current; - if (press?.status !== "pressing") { - return; - } - beginSelectionAtPoint({ x: press.startX, y: press.startY }); - }, TERMINAL_GESTURE_LONG_PRESS_MS); - }, - onPanResponderMove: (event, gesture) => { - const selectionPress = selectionPressRef.current; - const panState = panStateRef.current; - if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - panState.movedBeyondTapTolerance = true; - } - if (selectionPress?.status === "selecting") { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - return; - } - - if (panState.intent === "scroll") { - handlePanResponderScroll(gesture.dy); - return; - } - if (panState.didNavigate) { - return; - } - - if (selectionPress?.status === "pressing") { - const point = resolvePanResponderPoint(selectionPress, event, gesture); - const dx = point.x - selectionPress.startX; - const dy = point.y - selectionPress.startY; - const intent = classifyTerminalGestureIntent({ - status: "pressing", - dx, - dy, - vx: gesture.vx, - vy: gesture.vy, - elapsedMs: Date.now() - selectionPress.startedAt, - }); - if (intent === "tap" || intent === "pending") { - return; - } - - clearSelectionLongPressTimeout(); - panState.intent = intent; - - if (intent === "scroll") { - selectionPressRef.current = null; - handlePanResponderScroll(gesture.dy); - return; - } - - if (intent === "select") { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - updateSelectionAtPoint(point); - return; - } - } - }, - onPanResponderRelease: (event, gesture) => { - clearSelectionLongPressTimeout(); - const selectionPress = selectionPressRef.current; - if (selectionPress?.status === "selecting") { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - selectionPressRef.current = null; - const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const releaseAction = resolveTerminalGestureReleaseAction({ - status: selectionPress?.status ?? "idle", - startedWithSelection: selectionPress?.startedWithSelection, - didScroll, - didNavigate, - movedBeyondTapTolerance, - pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: terminalScreenRef.current?.scroll.mode ?? "following", - }); - if (releaseAction === "select" && selectionPress) { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - return; - } - if (releaseAction === "focus" || releaseAction === "clear") { - focusTerminalFromTap(); - } - }, - onPanResponderTerminate: () => { - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; + const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }; + gestureStartRef.current = point; + selectionController.press(point); }, + onPanResponderMove: (_event, gesture) => + selectionController.move({ + x: gestureStartRef.current.x + gesture.dx, + y: gestureStartRef.current.y + gesture.dy, + }), + onPanResponderRelease: () => selectionController.release(), + onPanResponderTerminate: () => selectionController.cancel(), }), - [ - beginSelectionAtPoint, - clearSelectionLongPressTimeout, - focusTerminalFromTap, - handlePanResponderScroll, - updateSelectionAtPoint, - ], + [selectionController], ); const backgroundColor = xtermTheme.background ?? "#0b0b0b"; @@ -976,6 +699,11 @@ function NativeTerminalEmulator({ const terminalState = terminalScreen?.viewport ?? null; const isScrolled = terminalScreen?.scroll.mode === "scrolled"; + const viewport = resolveSelectionViewport(); + const handles = + viewport && cellMetrics + ? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics }) + : []; const terminalGrid = terminalState ? ( {terminalGrid} + + {handles.map((handle) => ( + + + + + + + ))} + ; -interface PendingTerminalTap { - startX: number; - startY: number; - moved: boolean; -} - -function buildThemeKey(theme: ITheme): string { - return JSON.stringify(theme); -} - -function serializeForInjectedJavaScript(message: BridgeInboundMessage): string { - return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); -} - -function createMountMessage(input: { - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; -}): BridgeInboundMessage { - return { - type: "mount", - streamKey: input.streamKey, - initialSnapshot: input.initialSnapshot, - scrollbackLines: input.scrollbackLines, - theme: input.theme, - fontFamily: input.fontFamily, - fontSize: input.fontSize, - pendingModifiers: input.pendingModifiers, - swipeGesturesEnabled: input.swipeGesturesEnabled, - }; -} - -export default function WebViewTerminalEmulator({ - ref, - streamKey, - testId = "terminal-surface", - xtermTheme = { - background: "#0b0b0b", - foreground: "#e6e6e6", - cursor: "#e6e6e6", - }, - scrollbackLines, - fontFamily, - fontSize, - swipeGesturesEnabled = false, - onSwipeLeft, - onSwipeRight, - initialSnapshot = null, - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onRendererReadyChange, - pendingModifiers = { ctrl: false, shift: false, alt: false }, - focusRequestToken = 0, - resizeRequestToken = 0, -}: TerminalEmulatorProps) { - const webViewRef = useRef(null); - const [webViewEpoch, setWebViewEpoch] = useState(0); - const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0); - const bridgeReadyRef = useRef(false); - const bridgeReadyVersionRef = useRef(0); - const rendererReadyVersionRef = useRef(0); - const pendingMessagesRef = useRef([]); - const outputDecoderRef = useRef(new TextDecoder()); - const mountRequestedStreamKeyRef = useRef(null); - const rendererReadyStreamKeyRef = useRef(null); - const bridgeReadyTimeoutRef = useRef | null>(null); - const rendererReadyTimeoutRef = useRef | null>(null); - const pendingTapRef = useRef(null); - const mountConfigRef = useRef({ - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }); - mountConfigRef.current = { - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }; - const callbacksRef = useRef({ - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onSwipeLeft, - onSwipeRight, - }); - callbacksRef.current = { - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onSwipeLeft, - onSwipeRight, - }; - - const clearBridgeReadyTimeout = useCallback(() => { - if (bridgeReadyTimeoutRef.current === null) return; - clearTimeout(bridgeReadyTimeoutRef.current); - bridgeReadyTimeoutRef.current = null; - }, []); - - const clearRendererReadyTimeout = useCallback(() => { - if (rendererReadyTimeoutRef.current === null) return; - clearTimeout(rendererReadyTimeoutRef.current); - rendererReadyTimeoutRef.current = null; - }, []); - - const resetWebViewDocument = useCallback(() => { - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - setWebViewEpoch((value) => value + 1); - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]); - - const scheduleBridgeReadyWatchdog = useCallback(() => { - clearBridgeReadyTimeout(); - const expectedBridgeReadyVersion = bridgeReadyVersionRef.current; - bridgeReadyTimeoutRef.current = setTimeout(() => { - bridgeReadyTimeoutRef.current = null; - if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) { - return; - } - resetWebViewDocument(); - }, BRIDGE_READY_TIMEOUT_MS); - }, [clearBridgeReadyTimeout, resetWebViewDocument]); - - const scheduleRendererReadyWatchdog = useCallback(() => { - clearRendererReadyTimeout(); - const expectedRendererReadyVersion = rendererReadyVersionRef.current; - rendererReadyTimeoutRef.current = setTimeout(() => { - rendererReadyTimeoutRef.current = null; - if ( - !shouldResetForMissingRendererReady({ - expectedReadyVersion: expectedRendererReadyVersion, - currentReadyVersion: rendererReadyVersionRef.current, - expectedStreamKey: streamKey, - mountRequestedStreamKey: mountRequestedStreamKeyRef.current, - rendererReadyStreamKey: rendererReadyStreamKeyRef.current, - }) - ) { - return; - } - resetWebViewDocument(); - }, RENDERER_READY_TIMEOUT_MS); - }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]); - - const flushPendingMessages = useCallback(() => { - if (!bridgeReadyRef.current || !webViewRef.current) return; - const pending = pendingMessagesRef.current.splice(0); - for (const message of pending) { - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - } - }, []); - - const sendToWebView = useCallback((message: BridgeInboundMessage) => { - if (!bridgeReadyRef.current || !webViewRef.current) { - pendingMessagesRef.current.push(message); - return; - } - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - }, []); - - useImperativeHandle( - ref, - (): TerminalEmulatorHandle => ({ - find: { - setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }), - search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }), - clear: () => sendToWebView({ type: "clearFind", streamKey }), - }, - writeOutput: (data: TerminalOutputData) => { - const output = outputDecoderRef.current.decode(data, { stream: true }); - if (output.length === 0) { - return; - } - sendToWebView({ type: "writeOutput", streamKey, text: output }); - }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - if (text.length === 0) { - return; - } - sendToWebView({ type: "restoreOutput", streamKey, text }); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "renderSnapshot", streamKey, state }); - }, - paste: (text: string) => { - sendToWebView({ type: "paste", streamKey, text }); - }, - copySelection: async () => "", - clear: () => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "clear", streamKey }); - }, - claimSize: () => { - sendToWebView({ type: "resize", streamKey, forceClaim: true }); - }, - showKeyboard: () => { - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - webViewRef.current?.requestFocus(); - }, - blur: () => { - webViewRef.current?.injectJavaScript( - "window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;", - ); - Keyboard.dismiss(); - }, - }), - [sendToWebView, streamKey], - ); - - useEffect(() => { - outputDecoderRef.current.decode(); - }, [streamKey]); - - useEffect(() => { - if (bridgeReadyVersion <= 0) return; - const mountMessage = createMountMessage(mountConfigRef.current); - mountRequestedStreamKeyRef.current = streamKey; - rendererReadyStreamKeyRef.current = null; - sendToWebView(mountMessage); - flushPendingMessages(); - scheduleRendererReadyWatchdog(); - }, [ - bridgeReadyVersion, - flushPendingMessages, - scheduleRendererReadyWatchdog, - sendToWebView, - streamKey, - ]); - - const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]); - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme }); - }, [sendToWebView, streamKey, themeKey, xtermTheme]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); - }, [scrollbackLines, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize }); - }, [fontFamily, fontSize, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); - }, [pendingModifiers, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled }); - }, [sendToWebView, streamKey, swipeGesturesEnabled]); - - useEffect(() => { - if (focusRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, forceClaim: true }); - sendToWebView({ type: "focus", streamKey }); - webViewRef.current?.requestFocus(); - }, [focusRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - if (resizeRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false }); - }, [resizeRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - return () => { - if (mountRequestedStreamKeyRef.current) { - const previousStreamKey = mountRequestedStreamKeyRef.current; - callbacksRef.current.onRendererReadyChange?.({ - streamKey: previousStreamKey, - isReady: false, - }); - sendToWebView({ type: "unmount", streamKey: previousStreamKey }); - } - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - }; - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]); - - const handleLifecycleMessage = useCallback( - (message: BridgeOutboundMessage): boolean => { - if (message.type === "bridgeReady") { - bridgeReadyRef.current = true; - bridgeReadyVersionRef.current += 1; - clearBridgeReadyTimeout(); - setBridgeReadyVersion((value) => value + 1); - return true; - } - if (message.type === "rendererReady") { - if (message.streamKey === mountRequestedStreamKeyRef.current) { - rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null; - if (message.isReady) { - rendererReadyVersionRef.current += 1; - clearRendererReadyTimeout(); - } - } - callbacksRef.current.onRendererReadyChange?.({ - streamKey: message.streamKey, - isReady: message.isReady, - }); - return true; - } - return false; - }, - [clearBridgeReadyTimeout, clearRendererReadyTimeout], - ); - - const resolveLocalFileLink = useCallback( - async (message: Extract) => { - try { - const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ?? - null); - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target, - }); - } catch { - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target: null, - }); - } - }, - [sendToWebView], - ); - - const handleTerminalMessage = useCallback( - ( - message: Exclude, - ) => { - if (message.type === "resolveLocalFileLink") { - void resolveLocalFileLink(message); - return; - } - if (message.type === "openLocalFileLink") { - callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition); - return; - } - switch (message.type) { - case "input": - callbacksRef.current.onInput?.(message.data); - break; - case "resize": - callbacksRef.current.onResize?.({ - rows: message.rows, - cols: message.cols, - shouldClaim: message.shouldClaim !== false, - forceClaim: message.forceClaim, - }); - break; - case "terminalKey": - callbacksRef.current.onTerminalKey?.({ - key: message.key, - ctrl: message.ctrl, - shift: message.shift, - alt: message.alt, - meta: message.meta, - }); - break; - case "pendingModifiersConsumed": - callbacksRef.current.onPendingModifiersConsumed?.(); - break; - case "inputModeChange": - callbacksRef.current.onInputModeChange?.(message.state); - break; - case "openExternalUrl": - void openExternalUrl(message.url); - break; - case "swipeLeft": - callbacksRef.current.onSwipeLeft?.(); - break; - case "swipeRight": - callbacksRef.current.onSwipeRight?.(); - break; - case "debug": - break; - } - }, - [resolveLocalFileLink], - ); - - const handleMessage = useCallback( - (event: WebViewMessageEvent) => { - let message: BridgeOutboundMessage; - try { - message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage; - } catch { - return; - } - - if (message.type === "bridgeReady" || message.type === "rendererReady") { - handleLifecycleMessage(message); - return; - } - if ( - !shouldHandleTerminalBridgeMessage(message, { - desiredStreamKey: streamKey, - mountedStreamKey: mountRequestedStreamKeyRef.current, - }) - ) { - return; - } - if (message.type === "findRequest") { - callbacksRef.current.onFindRequest?.(); - return; - } - if (message.type === "findResult") { - callbacksRef.current.onFindResult?.(message.result); - return; - } - handleTerminalMessage(message); - }, - [handleLifecycleMessage, handleTerminalMessage, streamKey], - ); - - const handleLoadStart = useCallback>(() => { - bridgeReadyRef.current = false; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - scheduleBridgeReadyWatchdog(); - }, [scheduleBridgeReadyWatchdog]); - - const handleContentProcessDidTerminate = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleRenderProcessGone = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => { - pendingTapRef.current = { - startX: event.nativeEvent.pageX, - startY: event.nativeEvent.pageY, - moved: false, - }; - }, []); - - const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => { - const pendingTap = pendingTapRef.current; - if (!pendingTap) return; - const dx = event.nativeEvent.pageX - pendingTap.startX; - const dy = event.nativeEvent.pageY - pendingTap.startY; - if ( - Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || - Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX - ) { - pendingTap.moved = true; - } - }, []); - - const handleWebViewTouchEnd = useCallback(() => { - const pendingTap = pendingTapRef.current; - pendingTapRef.current = null; - if (!pendingTap || pendingTap.moved) { - return; - } - webViewRef.current?.requestFocus(); - callbacksRef.current.onFocus?.(); - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - }, [sendToWebView, streamKey]); - - const handleWebViewTouchCancel = useCallback(() => { - pendingTapRef.current = null; - }, []); - - const backgroundColor = xtermTheme.background ?? "#0b0b0b"; - const rootStyle = useMemo>( - () => [styles.root, { backgroundColor }], - [backgroundColor], - ); - const webViewStyle = useMemo>( - () => [styles.webView, { backgroundColor }], - [backgroundColor], - ); - const webViewContainerStyle = useMemo>( - () => [styles.webViewContainer, { backgroundColor }], - [backgroundColor], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - minHeight: 0, - minWidth: 0, - overflow: "hidden", - }, - webView: { - flex: 1, - }, - webViewContainer: { - flex: 1, - }, -}); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 052d7a02f0..0635291c4c 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -2,10 +2,8 @@ import { useEffect } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { useAppSettings } from "../hooks/use-settings"; import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability"; import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native"; -import WebViewTerminalEmulator from "./terminal-emulator-webview.native"; import type { TerminalEmulatorProps } from "./terminal-emulator-contract"; export type { TerminalEmulatorHandle } from "./terminal-emulator-contract"; @@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina } export default function TerminalEmulator(props: TerminalEmulatorProps) { - const { settings } = useAppSettings(); - const { useLegacyTerminalRenderer } = settings; const rendererSelection = resolveTerminalRendererSelection({ - useLegacyTerminalRenderer, supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay, }); @@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) { return ; } - const Renderer = - rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator; - - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index be12355bea..7504843c54 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -11,9 +11,7 @@ import { useImperativeHandle, useMemo, useRef, - useState, type CSSProperties, - type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type Ref, } from "react"; @@ -21,7 +19,6 @@ import type { DOMProps } from "expo/dom"; import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { @@ -29,6 +26,8 @@ import { type TerminalOutputData, } from "../terminal/runtime/terminal-emulator-runtime"; import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; +import type { TerminalFile } from "../terminal/file-paste"; +import type { TerminalCharacterWidths } from "../terminal/character-widths"; import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, @@ -37,19 +36,11 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; -import { - extractTerminalDropPaths, - isTerminalDragLeaveOutside, - isTerminalFileDrag, - prepareDroppedPathsForTerminal, -} from "../terminal/drop/terminal-file-drop"; -import { getDesktopHost } from "@/desktop/host"; export interface TerminalEmulatorHandle { find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; + replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void; paste: (text: string) => void; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -106,6 +97,7 @@ interface TerminalEmulatorProps { ref: Ref; streamKey: string; supportsTerminalInputModeReplay: boolean; + characterWidths: TerminalCharacterWidths; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; @@ -116,7 +108,6 @@ interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; @@ -145,6 +136,7 @@ interface TerminalEmulatorProps { disposition: "main" | "side", ) => Promise | void; onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; + onFilePaste?: (file: Promise) => void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; @@ -154,16 +146,6 @@ declare global { interface Window {} } -function isTerminalState(value: unknown): value is TerminalState { - return ( - typeof value === "object" && - value !== null && - "rows" in value && - "cols" in value && - "grid" in value - ); -} - export default function TerminalEmulator({ ref, streamKey, @@ -174,12 +156,12 @@ export default function TerminalEmulator({ cursor: "#e6e6e6", }, scrollbackLines, + characterWidths, fontFamily, fontSize, swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, - initialSnapshot = null, onFindRequest, onFindResult, onInput, @@ -191,6 +173,7 @@ export default function TerminalEmulator({ onResolveLocalFileLink, onOpenLocalFileLink, onRendererReadyChange, + onFilePaste, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, @@ -203,6 +186,8 @@ export default function TerminalEmulator({ const fontSizeRef = useRef(fontSize); const scrollbackLinesRef = useRef(scrollbackLines); scrollbackLinesRef.current = scrollbackLines; + const characterWidthsRef = useRef(characterWidths); + characterWidthsRef.current = characterWidths; fontFamilyRef.current = fontFamily; fontSizeRef.current = fontSize; const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); @@ -220,6 +205,7 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, }); mountCallbacksRef.current = { onFindRequest, @@ -231,13 +217,10 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, }; - const initialSnapshotRef = useRef(initialSnapshot); - initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; - const [isDropActive, setIsDropActive] = useState(false); - const dropActiveTimeoutRef = useRef | null>(null); const domBridgeRef = useRef(null); const pasteText = useCallback((text: string) => { @@ -259,17 +242,17 @@ export default function TerminalEmulator({ const data = args[0]; if (data instanceof Uint8Array) runtimeRef.current?.write({ data }); }, - restoreOutput: (...args) => { - const data = args[0]; - if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (...args) => { - const state = args[0]; - if (state === null) { - runtimeRef.current?.renderSnapshot({ state: null }); - } else if (isTerminalState(state)) { - runtimeRef.current?.renderSnapshot({ state }); - } + replay: (event) => { + if (!event || typeof event !== "object" || Array.isArray(event)) return; + if (event.type === "end") runtimeRef.current?.replay({ type: "end" }); + if (event.type === "data" && event.data instanceof Uint8Array) + runtimeRef.current?.replay({ type: "data", data: event.data }); + if (event.type === "begin") + runtimeRef.current?.replay({ + type: "begin", + rows: typeof event.rows === "number" ? event.rows : undefined, + cols: typeof event.cols === "number" ? event.cols : undefined, + }); }, paste: (...args) => { const text = args[0]; @@ -305,12 +288,7 @@ export default function TerminalEmulator({ writeOutput: (data: TerminalOutputData) => { runtimeRef.current?.write({ data }); }, - restoreOutput: (data: TerminalOutputData) => { - runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (state: TerminalState | null) => { - runtimeRef.current?.renderSnapshot({ state }); - }, + replay: (event) => runtimeRef.current?.replay(event), paste: (text: string) => { pasteText(text); }, @@ -342,6 +320,10 @@ export default function TerminalEmulator({ runtimeRef.current?.setScrollback({ lines: scrollbackLines }); }, [scrollbackLines]); + useEffect(() => { + runtimeRef.current?.setCharacterWidths({ widths: characterWidths }); + }, [characterWidths]); + useEffect(() => { const root = rootRef.current; if (!root || !swipeGesturesEnabled) { @@ -474,10 +456,10 @@ export default function TerminalEmulator({ }, }); runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current }); + runtime.setCharacterWidths({ widths: characterWidthsRef.current }); runtime.mount({ root, host, - initialSnapshot: initialSnapshotRef.current, scrollback: scrollbackLinesRef.current, theme: mountedThemeRef.current, fontFamily: fontFamilyRef.current, @@ -506,11 +488,13 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, onOpenExternalUrl: openExternalUrl, }, }); }, [ onFindRequest, + onFilePaste, onFindResult, onInput, onInputModeChange, @@ -582,111 +566,6 @@ export default function TerminalEmulator({ [showTerminalContextMenu], ); - const clearDropActiveTimeout = useCallback(() => { - if (dropActiveTimeoutRef.current === null) { - return; - } - clearTimeout(dropActiveTimeoutRef.current); - dropActiveTimeoutRef.current = null; - }, []); - - const clearTerminalDropActive = useCallback(() => { - clearDropActiveTimeout(); - setIsDropActive(false); - }, [clearDropActiveTimeout]); - - const keepTerminalDropActive = useCallback(() => { - clearDropActiveTimeout(); - setIsDropActive(true); - dropActiveTimeoutRef.current = setTimeout(() => { - dropActiveTimeoutRef.current = null; - setIsDropActive(false); - }, 180); - }, [clearDropActiveTimeout]); - - useEffect(() => { - const root = rootRef.current; - if (!root) { - return () => {}; - } - - const handleDragEnter = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - keepTerminalDropActive(); - }; - - const handleDragOver = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "copy"; - } - keepTerminalDropActive(); - }; - - const handleDrop = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - clearTerminalDropActive(); - - const bridge = getDesktopHost(); - const paths = extractTerminalDropPaths(event.dataTransfer, bridge); - if (paths.length === 0) { - return; - } - - runtimeRef.current?.focus(); - mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge)); - }; - - root.addEventListener("dragenter", handleDragEnter, { capture: true }); - root.addEventListener("dragover", handleDragOver, { capture: true }); - root.addEventListener("drop", handleDrop, { capture: true }); - window.addEventListener("dragend", clearTerminalDropActive); - window.addEventListener("drop", clearTerminalDropActive); - - return () => { - root.removeEventListener("dragenter", handleDragEnter, { capture: true }); - root.removeEventListener("dragover", handleDragOver, { capture: true }); - root.removeEventListener("drop", handleDrop, { capture: true }); - window.removeEventListener("dragend", clearTerminalDropActive); - window.removeEventListener("drop", clearTerminalDropActive); - clearDropActiveTimeout(); - }; - }, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]); - - const handleRootDragLeave = useCallback( - (event: ReactDragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - if ( - !isTerminalDragLeaveOutside({ - currentTarget: event.currentTarget, - relatedTarget: event.relatedTarget, - }) - ) { - return; - } - clearTerminalDropActive(); - }, - [clearTerminalDropActive], - ); - const rootDivStyle = useMemo( () => ({ position: "relative", @@ -702,19 +581,6 @@ export default function TerminalEmulator({ }), [xtermTheme.background], ); - const dropOverlayStyle = useMemo( - () => ({ - position: "absolute", - inset: 0, - zIndex: 9, - border: "1px solid rgba(78, 161, 255, 0.72)", - backgroundColor: "rgba(78, 161, 255, 0.16)", - opacity: isDropActive ? 1 : 0, - pointerEvents: "none", - transition: "opacity 120ms ease-out", - }), - [isDropActive], - ); return (
-
); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 31c36b607c..4c5e74ef6d 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,5 +1,6 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find"; import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime"; +import type { TerminalCharacterWidths } from "@/terminal/character-widths"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -15,7 +16,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated" import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { DEFAULT_TERMINAL_INPUT_MODE_STATE, type TerminalInputModeState, @@ -43,8 +43,23 @@ import { shouldShowTerminalPasteAction, type TerminalVirtualKeyboardControl, } from "@/terminal/runtime/terminal-virtual-keyboard"; -import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; -import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; +import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; +import { + TerminalFilePasteQueue, + TerminalFileTooLargeError, + terminalFileFromBase64, + type TerminalFile, +} from "@/terminal/file-paste"; +import { useToast } from "@/contexts/toast-context"; +import type { DismissToast } from "@/components/toast-host"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { useFileDrop } from "@/components/file-drop/use-file-drop"; +import type { DroppedItem } from "@/components/file-drop/types"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { getDesktopHost } from "@/desktop/host"; +import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; +import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; +import { toErrorMessage } from "@/utils/error-messages"; import { EMPTY_FOCUS_CLAIM_STATE, canRequestFocusClaim, @@ -72,7 +87,6 @@ import { isNative } from "@/constants/platform"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, - shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, type TerminalRendererReadyChange, } from "@/utils/terminal-renderer-readiness"; @@ -122,6 +136,24 @@ type PendingTerminalInput = input: TerminalKeyInput; }; +async function readNativeClipboardImage(): Promise { + const image = await Clipboard.getImageAsync({ format: "png" }); + if (!image) { + return null; + } + return terminalFileFromBase64({ + fileName: "clipboard.png", + mimeType: "image/png", + data: image.data.slice(image.data.indexOf(",") + 1), + }); +} + +/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */ +function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) { + useFileDrop({ onGenericFiles: onFiles }); + return null; +} + function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } @@ -273,6 +305,11 @@ export function TerminalPane({ const supportsTerminalSizeOwnership = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true, ); + const characterWidths: TerminalCharacterWidths = useSessionStore((state) => + state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true + ? "graphemes" + : "legacy", + ); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); @@ -282,19 +319,53 @@ export function TerminalPane({ const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); - const workspaceTerminalSession = useMemo( - () => getWorkspaceTerminalSession({ scopeKey }), - [scopeKey], - ); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [hasSelection, setHasSelection] = useState(false); const [hasClipboardText, setHasClipboardText] = useState(false); + const [hasClipboardImage, setHasClipboardImage] = useState(false); const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); + const toast = useToast(); + const dismissUploadingToastRef = useRef(null); + const showFileUploading = useStableEvent((uploading: boolean) => { + dismissUploadingToastRef.current?.(); + dismissUploadingToastRef.current = uploading + ? toast.show(t("workspace.terminal.uploadingFile"), { + durationMs: null, + testID: "terminal-file-uploading", + }) + : null; + }); + const reportFileUploaded = useStableEvent(() => { + toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" }); + }); + const reportFilePasteFailure = useStableEvent((error: unknown) => { + const reason = + error instanceof TerminalFileTooLargeError + ? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName }) + : toErrorMessage(error); + toast.error(t("workspace.terminal.pasteFileFailed", { reason })); + }); + const [filePastes] = useState( + () => + new TerminalFilePasteQueue({ + onUploadingChange: showFileUploading, + onUploaded: reportFileUploaded, + onFailure: reportFilePasteFailure, + }), + ); + useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]); + useEffect(() => { + if (!isConnected && filePastes.cancel()) { + reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected"))); + } + }, [filePastes, isConnected, reportFilePasteFailure, t]); + // Only the desktop app's own daemon can read files on this machine by path. + const isLocalHost = useIsLocalDaemon(serverId); useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection); const emulatorRef = useRef(null); const findRef = useRef(null); @@ -311,7 +382,6 @@ export function TerminalPane({ const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE); - const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); useEffect(() => { terminalIdRef.current = terminalId; @@ -325,10 +395,15 @@ export function TerminalPane({ return; } try { - const hasText = await Clipboard.hasStringAsync(); + const [hasText, hasImage] = await Promise.all([ + Clipboard.hasStringAsync(), + Clipboard.hasImageAsync(), + ]); setHasClipboardText(hasText); + setHasClipboardImage(hasImage); } catch { setHasClipboardText(false); + setHasClipboardImage(false); } }, [showVirtualKeyBar]); @@ -364,16 +439,8 @@ export function TerminalPane({ (change: TerminalRendererReadyChange) => { if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset(); setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change)); - if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) { - return; - } - - const snapshot = workspaceTerminalSession.snapshots.get({ terminalId }); - if (snapshot) { - emulatorRef.current?.renderSnapshot(snapshot); - } }, - [terminalId, terminalStreamKey, workspaceTerminalSession.snapshots], + [terminalStreamKey], ); useEffect(() => { @@ -487,7 +554,6 @@ export function TerminalPane({ ); const handleStreamExit = useStableEvent((exitedTerminalId: string) => { - workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); setModifiers({ ...EMPTY_MODIFIERS }); }); @@ -527,29 +593,10 @@ export function TerminalPane({ }, ); - const handleStreamRestore = useStableEvent( - ({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => { - workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId }); - if (terminalIdRef.current !== restoreTerminalId) { - return; - } - emulatorRef.current?.restoreOutput(data); - }, - ); - - const handleStreamSnapshot = useStableEvent( - ({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => { - workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); - if (terminalIdRef.current !== snapshotTerminalId) { - return; - } - emulatorRef.current?.renderSnapshot(state); - }, - ); - const getStreamRestoreOptions = useStableEvent(() => resolveTerminalRestoreOptions({ supportsTerminalRestoreModes, + scrollbackLines: settings.terminalScrollbackLines, canClaimSize: canRequestFocusClaim({ isWorkspaceFocused: terminalPresentedRef.current, isPaneFocused, @@ -576,8 +623,9 @@ export function TerminalPane({ client, getPreferredSize: getPreferredStreamSize, onOutput: handleStreamOutput, - onRestore: handleStreamRestore, - onSnapshot: handleStreamSnapshot, + onReplay: ({ terminalId: id, replay }) => { + if (terminalIdRef.current === id) emulatorRef.current?.replay(replay); + }, onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, @@ -597,8 +645,6 @@ export function TerminalPane({ getStreamRestoreOptions, handleStreamControllerStatus, handleStreamOutput, - handleStreamRestore, - handleStreamSnapshot, handleStreamExit, isConnected, ]); @@ -705,6 +751,9 @@ export function TerminalPane({ alt: boolean; meta?: boolean; }): boolean => { + if (input.ctrl && input.key.toLowerCase() === "c") { + filePastes.cancel(); + } if (!client || !terminalIdRef.current) { enqueuePendingTerminalInput({ type: "key", @@ -740,7 +789,7 @@ export function TerminalPane({ } return true; }, - [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput], + [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes], ); const handleTerminalData = useCallback( @@ -748,6 +797,10 @@ export function TerminalPane({ if (data.length === 0) { return; } + // Ctrl+C abandons file pastes that have not landed yet. + if (data.includes("\x03")) { + filePastes.cancel(); + } if (hasPendingTerminalModifiers(modifiers)) { const pendingResolution = resolvePendingModifierDataInput({ @@ -793,12 +846,59 @@ export function TerminalPane({ clearPendingModifiers, client, dispatchTerminalInputEntry, + filePastes, modifiers, sendTerminalKey, enqueuePendingTerminalInput, ], ); + const pasteTerminalText = useStableEvent((text: string) => { + const entry: PendingTerminalInput = { + type: "data", + data: encodeTerminalPaste({ + text, + bracketedPaste: inputModeRef.current.bracketedPaste ?? false, + }), + }; + if (!dispatchTerminalInputEntry(entry)) { + enqueuePendingTerminalInput(entry); + } + }); + + const handleFilePaste = useStableEvent((file: Promise) => { + filePastes.add(file, { + upload: async (upload) => { + if (!client || !isConnected) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const response = await client.uploadFile(upload); + if (!response.file) { + throw new Error(response.error ?? "Upload failed"); + } + return response.file.path; + }, + paste: pasteTerminalText, + }); + }); + + const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { + const bridge = getDesktopHost(); + const paths: string[] = []; + for (const item of items) { + const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge }); + if (delivery.kind === "path") { + paths.push(delivery.path); + } else { + handleFilePaste(delivery.read()); + } + } + if (paths.length > 0) { + pasteTerminalText(prepareDroppedPathsForTerminal(paths)); + } + requestTerminalFocus(); + }); + const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => { const nextSize = { rows: input.rows, cols: input.cols }; const claim = resolveTerminalResizeClaim({ @@ -876,11 +976,15 @@ export function TerminalPane({ const handleTerminalPaste = useCallback(() => { requestTerminalReflow(); - void pasteTerminalClipboard({ + const image = pasteTerminalClipboard({ clipboard: { readText: () => Clipboard.getStringAsync() }, terminal: { paste: (text) => emulatorRef.current?.paste(text) }, - }).then(() => refreshClipboardAvailability()); - }, [requestTerminalReflow, refreshClipboardAvailability]); + }) + .catch(() => false) + .then((pastedText) => (pastedText ? null : readNativeClipboardImage())); + handleFilePaste(image); + void image.catch(() => null).then(() => refreshClipboardAvailability()); + }, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]); const handleTerminalCopy = useCallback(() => { void emulatorRef.current @@ -1032,7 +1136,7 @@ export function TerminalPane({ return showPasteAction ? ( ) : null; @@ -1065,13 +1169,15 @@ export function TerminalPane({ return ( - + + ) : null} - + {streamError ? ( @@ -1171,7 +1277,7 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, attachOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0, 0, 0, 0.16)", diff --git a/packages/app/src/components/terminal-webview-message-routing.test.ts b/packages/app/src/components/terminal-webview-message-routing.test.ts deleted file mode 100644 index 7a08aa19d9..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; - -describe("terminal WebView message routing", () => { - it("rejects terminal messages from a stale stream", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(false); - expect( - shouldHandleTerminalBridgeMessage( - { type: "resize", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(false); - }); - - it("rejects the old WebView stream while React is switching callbacks", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" }, - ), - ).toBe(false); - }); - - it("accepts messages from the mounted stream and unscoped diagnostics", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-b" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(true); - expect( - shouldHandleTerminalBridgeMessage( - { type: "debug" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(true); - }); -}); diff --git a/packages/app/src/components/terminal-webview-message-routing.ts b/packages/app/src/components/terminal-webview-message-routing.ts deleted file mode 100644 index c23c6b7244..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.ts +++ /dev/null @@ -1,17 +0,0 @@ -interface TerminalBridgeMessageIdentity { - type: string; - streamKey?: string; -} - -export function shouldHandleTerminalBridgeMessage( - message: TerminalBridgeMessageIdentity, - stream: { - desiredStreamKey: string; - mountedStreamKey: string | null; - }, -): boolean { - return ( - message.streamKey === undefined || - (message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey) - ); -} diff --git a/packages/app/src/components/terminal-webview-readiness.test.ts b/packages/app/src/components/terminal-webview-readiness.test.ts deleted file mode 100644 index 00db851676..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; - -describe("legacy terminal WebView readiness", () => { - it("resets when a requested mount never reports renderer readiness", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: null, - }), - ).toBe(true); - }); - - it("keeps the document after the requested renderer confirms readiness", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: "terminal-a", - }), - ).toBe(false); - }); - - it("ignores a watchdog from an earlier ready generation", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 1, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: null, - }), - ).toBe(false); - }); - - it("does not let readiness from a stale stream satisfy the requested mount", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-b", - mountRequestedStreamKey: "terminal-b", - rendererReadyStreamKey: "terminal-a", - }), - ).toBe(true); - }); - - it("does not reset a newer mount from an expired watchdog", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-b", - rendererReadyStreamKey: null, - }), - ).toBe(false); - }); -}); diff --git a/packages/app/src/components/terminal-webview-readiness.ts b/packages/app/src/components/terminal-webview-readiness.ts deleted file mode 100644 index a0cf804156..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function shouldResetForMissingRendererReady(input: { - expectedReadyVersion: number; - currentReadyVersion: number; - expectedStreamKey: string; - mountRequestedStreamKey: string | null; - rendererReadyStreamKey: string | null; -}): boolean { - return ( - input.currentReadyVersion === input.expectedReadyVersion && - input.mountRequestedStreamKey === input.expectedStreamKey && - input.rendererReadyStreamKey !== input.expectedStreamKey - ); -} diff --git a/packages/app/src/components/toast-host.test.tsx b/packages/app/src/components/toast-host.test.tsx new file mode 100644 index 0000000000..32f1bfb8a7 --- /dev/null +++ b/packages/app/src/components/toast-host.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useToastHost } from "./toast-host"; + +describe("useToastHost", () => { + it("dismisses the toast that show returned the dismiss for", () => { + const { result } = renderHook(() => useToastHost()); + + let dismiss = () => {}; + act(() => { + dismiss = result.current.api.show("Uploading image…", { durationMs: null }); + }); + expect(result.current.toast?.content).toBe("Uploading image…"); + + act(() => dismiss()); + expect(result.current.toast).toBeNull(); + }); + + it("leaves a newer toast in place when an older one is dismissed", () => { + const { result } = renderHook(() => useToastHost()); + + let dismissUploading = () => {}; + act(() => { + dismissUploading = result.current.api.show("Uploading image…", { durationMs: null }); + }); + act(() => { + result.current.api.show("Image uploaded", { variant: "success" }); + }); + + act(() => dismissUploading()); + expect(result.current.toast?.content).toBe("Image uploaded"); + }); +}); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 6b9009dd6a..62a15bad04 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -1,3 +1,7 @@ +import { + FloatingHeaderOverlay, + useFloatingHeaderOverlayClearance, +} from "@/components/floating-header"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { @@ -43,8 +47,11 @@ export interface ToastState { testID?: string; } +/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */ +export type DismissToast = () => void; + export interface ToastApi { - show: (content: ReactNode, options?: ToastShowOptions) => void; + show: (content: ReactNode, options?: ToastShowOptions) => DismissToast; copied: (label?: string) => void; error: (message: string) => void; } @@ -52,6 +59,7 @@ export interface ToastApi { type ToastViewportPlacement = "app-shell" | "panel"; const DEFAULT_DURATION_MS = 2200; +const noopDismiss: DismissToast = () => {}; const TOAST_MAX_WIDTH = 480; const toastEntering = FadeIn.duration(140); const toastExiting = FadeOut.duration(140); @@ -73,10 +81,10 @@ export function useToastHost(): { const [toast, setToast] = useState(null); const idRef = useRef(0); - const show = useCallback((content: ReactNode, options?: ToastShowOptions) => { + const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => { const nativeMessage = typeof content === "string" ? content.trim() : null; if (!content || nativeMessage === "") { - return; + return noopDismiss; } const variant = options?.variant ?? "default"; @@ -87,12 +95,13 @@ export function useToastHost(): { const duration = durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); - return; + return noopDismiss; } idRef.current += 1; + const id = idRef.current; setToast({ - id: idRef.current, + id, content, nativeMessage, icon: options?.icon, @@ -100,6 +109,7 @@ export function useToastHost(): { durationMs, testID: options?.testID, }); + return () => setToast((current) => (current?.id === id ? null : current)); }, []); const api = useMemo( @@ -184,9 +194,11 @@ export function ToastViewport({ return clearTimer; }, [clearTimer, durationMs, scheduleDismiss, toastId]); + const panelClearance = useFloatingHeaderOverlayClearance(); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0; + const topOffset = + placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance; const content = ( {content} : content; } function ToastCard({ diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 50e4052118..5e08f944df 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { View, Text, @@ -14,7 +14,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildPaseoToolDetailSections, type PaseoToolDetailSection, -} from "@getpaseo/protocol/paseo-tool-call-detail"; +} from "@getpaseo/protocol/paseo-tool-presentation/index"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { highlightDiffLines } from "@/utils/diff-highlight"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; @@ -64,7 +64,7 @@ function resolveShouldFill( ): boolean { if (!fillAvailableHeight) return false; const t = detail?.type; - return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent"; + return t === "shell" || t === "edit" || t === "write" || t === "read"; } function useDetailStyles( @@ -186,226 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { ); } -interface WorktreeSetupDetailProps { - log: string; - branchName: string; - worktreePath: string; - ds: DetailStyles; -} - -function WorktreeSetupDetailSection({ - log, - branchName, - worktreePath, - ds, -}: WorktreeSetupDetailProps) { - const setupLog = log.replace(/^\n+/, ""); - const hasLog = setupLog.length > 0; - return ( - - - - - - - {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`} - - - - - - - ); -} - -function resolveSubAgentFallbackHeader( - subAgentType: string | null | undefined, - description: string | null | undefined, - fallbackText: string, -): string { - if (subAgentType && description) { - return `${subAgentType}: ${description}`; - } - return subAgentType ?? description ?? fallbackText; -} - -interface SubAgentDetailProps { - log: string; - childSessionId: string | null | undefined; - subAgentType: string | null | undefined; - description: string | null | undefined; - ds: DetailStyles; -} - -interface SubAgentActivityRow { - index: number; - toolName: string; - summary?: string; -} - -interface ParsedSubAgentLog { - actions: SubAgentActivityRow[]; - remainingLog: string; -} - -function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null { - const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/); - if (!match) { - return null; - } - const toolName = match[1]?.trim(); - if (!toolName) { - return null; - } - const summary = match[2]?.trim(); - return { - index, - toolName, - ...(summary ? { summary } : {}), - }; -} - -function parseSubAgentLog(log: string): ParsedSubAgentLog { - const actions: SubAgentActivityRow[] = []; - const remainingLines: string[] = []; - for (const line of log.replace(/^\n+/, "").split("\n")) { - const normalizedLine = line.trim(); - if (!normalizedLine) { - continue; - } - const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1); - if (parsedAction) { - actions.push(parsedAction); - } else { - remainingLines.push(line); - } - } - return { - actions, - remainingLog: remainingLines.join("\n").replace(/^\n+/, ""), - }; -} - -function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) { - return ( - - - {formatSubAgentToolName(action.toolName)} - - {action.summary ? ( - - {action.summary} - - ) : null} - - ); -} - -function formatSubAgentToolName(toolName: string): string { - const trimmed = toolName.trim(); - if (!trimmed) { - return toolName; - } - return trimmed - .replace(/[._-]+/g, " ") - .split(" ") - .filter((segment) => segment.length > 0) - .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) - .join(" "); -} - -function SubAgentLogText({ - activityLog, - fallbackHeader, - hasActions, -}: { - activityLog: string; - fallbackHeader: string; - hasActions: boolean; -}) { - if (activityLog.length > 0) { - return ( - - {activityLog} - - ); - } - if (!hasActions) { - return ( - - {fallbackHeader} - - ); - } - return null; -} - -function SubAgentDetailSection({ - log, - childSessionId, - subAgentType, - description, - ds, -}: SubAgentDetailProps) { - const { t } = useTranslation(); - const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); - const fallbackHeader = resolveSubAgentFallbackHeader( - subAgentType, - description, - t("toolCallDetails.subAgentActivity"), - ); - const hasActions = actions.length > 0; - return ( - - - - - - {childSessionId ? ( - - session {childSessionId} - - ) : null} - {hasActions ? ( - - {actions.map((action) => ( - - ))} - - ) : null} - - - - - - - ); -} - interface EditDetailProps { diffLines: DiffLine[] | undefined; ds: DetailStyles; @@ -678,29 +458,7 @@ function buildDetailSections( , ]; } - if (detail.type === "worktree_setup") { - return [ - , - ]; - } - if (detail.type === "sub_agent") { - return [ - , - ]; - } + if (detail.type === "worktree_setup" || detail.type === "sub_agent") return []; if (detail.type === "edit") { return []; } @@ -952,34 +710,6 @@ const styles = StyleSheet.create((theme) => { shellPrompt: { color: theme.colors.foregroundMuted, }, - subAgentSessionText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - marginBottom: theme.spacing[2], - }, - subAgentActions: { - gap: theme.spacing[1], - marginBottom: theme.spacing[2], - }, - subAgentActionRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - subAgentActionTool: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - }, - subAgentActionSummary: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - lineHeight: 18, - }, jsonScroll: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index 9b1dcaf23d..e74c068658 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -5,36 +5,24 @@ import { useRef, useState, type ReactElement, - type RefObject, + type ReactNode, } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, -} from "react-native-reanimated"; +import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/keyboard/shift"; +import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; const OFFSET_FROM_ANCHOR = SPACING[3]; -interface Rect { - x: number; - y: number; - width: number; - height: number; -} - interface RelativeAnchorRect { x: number; y: number; @@ -42,17 +30,10 @@ interface RelativeAnchorRect { hostHeight: number; } -function measureElement(element: View): Promise { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - interface AutocompletePopoverProps { visible: boolean; - anchorRef: RefObject; + children: ReactNode; + anchorStyle?: StyleProp; options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; @@ -64,7 +45,8 @@ interface AutocompletePopoverProps { export function AutocompletePopover({ visible, - anchorRef, + children, + anchorStyle, options, selectedIndex, onSelect, @@ -75,12 +57,14 @@ export function AutocompletePopover({ }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. + const anchorRef = useRef(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState(null); const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - const { shift, isMoving } = useKeyboardShift(); - const measuredShift = useSharedValue(0); + const { isMoving } = useKeyboardShift(); + const anchor = useKeyboardAnchor(); + const { measureAtRest } = anchor; const measurementGeneration = useRef(0); const canMeasure = visible && (options.length === 0 || selectedIndex >= 0); @@ -90,20 +74,21 @@ export function AutocompletePopover({ if (!anchorElement) return; const generation = measurementGeneration.current; void Promise.all([ - measureElement(anchorElement), + measureAtRest(anchorElement), measureFloatingPanelPortalHost(portalHostName), ]).then(([anchorRect, hostRect]) => { - if (generation !== measurementGeneration.current || !hostRect) return undefined; + if (generation !== measurementGeneration.current || !anchorRect || !hostRect) { + return undefined; + } setRelativeAnchorRect({ x: anchorRect.x - hostRect.x, y: anchorRect.y - hostRect.y, width: anchorRect.width, hostHeight: hostRect.height, }); - measuredShift.value = shift.value; return undefined; }); - }, [anchorRef, canMeasure, measuredShift, portalHostName, shift]); + }, [anchorRef, canMeasure, measureAtRest, portalHostName]); useEffect(() => { measurementGeneration.current += 1; @@ -131,58 +116,53 @@ export function AutocompletePopover({ [isMoving, remeasure], ); + // Lay out at the anchor's resting position; the KeyboardTranslateView below lifts + // it with the anchor. Height changes only at keyboard boundaries. const baseStyle = useMemo(() => { if (!relativeAnchorRect) return null; return inlineUnistylesStyle({ position: "absolute" as const, left: relativeAnchorRect.x, width: relativeAnchorRect.width, + bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR, }); }, [relativeAnchorRect]); const anchorY = relativeAnchorRect?.y ?? 0; - const baseBottom = relativeAnchorRect - ? relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR - : 0; - const keyboardLayoutStyle = useAnimatedStyle(() => { - const shiftDelta = shift.value - measuredShift.value; - return { - bottom: baseBottom + shiftDelta, - maxHeight: Math.max(0, anchorY - shiftDelta - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2), - }; - }, [anchorY, baseBottom, safeAreaInsets.top]); + const reservedLift = anchor.reservedLift; + const heightStyle = useAnimatedStyle( + () => ({ + maxHeight: Math.max( + 0, + anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2, + ), + }), + [anchorY, reservedLift, safeAreaInsets.top], + ); - if (!visible || !relativeAnchorRect || !baseStyle) return null; - if (options.length > 0 && selectedIndex < 0) return null; + const showPopover = canMeasure && relativeAnchorRect && baseStyle; + // The translate view is the popover's own frame, so it lifts only the popover. return ( - - - - - - - + + {children} + {showPopover ? ( + + + + + + + + ) : null} + ); } - -const styles = StyleSheet.create(() => ({ - overlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - }, -})); diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 7b5b99b316..a9cb10f33e 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -247,14 +247,16 @@ export function Autocomplete({ {selectedOption?.kind === "command" && selectedOption.description ? ( - + {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - + {removeBoltGlyphs(selectedOption.description)} {selectedOption.detail ? ( - {removeBoltGlyphs(selectedOption.detail)} + + {removeBoltGlyphs(selectedOption.detail)} + ) : null} ) : null} @@ -293,6 +295,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[1], }, detailCard: { + flexShrink: 1, + minHeight: 0, + overflow: "hidden", backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, @@ -318,7 +323,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, container: { flexShrink: 1, - minHeight: 0, + // Keep a complete selectable row (36), list padding (8), and borders (2). + minHeight: 46, backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 707ce60648..e4a9a13b2b 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useContainedControlSize } from "@/components/ui/control-size"; import { default as React, useCallback, @@ -162,11 +163,12 @@ const styles = StyleSheet.create((theme) => { export function Button({ children, variant = "secondary", - size = "md", + size: sizeProp, leftIcon, trailing, style, textStyle, + numberOfLines, disabled, loading = false, accessibilityRole, @@ -181,10 +183,13 @@ export function Button({ trailing?: ReactNode; style?: StyleProp; textStyle?: StyleProp; + numberOfLines?: number; loading?: boolean; } >) { const [hovered, setHovered] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = disabled || loading; let variantStyle: ViewStyle; @@ -274,7 +279,11 @@ export function Button({ iconSize={buttonIconSize[size]} uniProps={resolveIconMapping()} /> - {children != null ? {children} : null} + {children != null ? ( + + {children} + + ) : null} {trailing} ); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index caf73d1bbc..adcd91afcb 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, @@ -12,7 +13,6 @@ import { View, Text, Pressable, - Modal, ScrollView, Platform, StatusBar, @@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -910,13 +911,9 @@ function resolveInitialActiveIndex( return selectedIndex >= 0 ? selectedIndex : fallbackIndex; } -type BottomSheetVisibility = ReturnType; - interface MobileBodyProps { - bottomSheetRef: BottomSheetVisibility["sheetRef"]; + isOpen: boolean; snapPoints: string[]; - handleSheetChange: BottomSheetVisibility["handleSheetChange"]; - handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"]; handleIndicatorStyle: { backgroundColor: string }; titleColor: string; title: string; @@ -941,29 +938,28 @@ interface MobileBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; children: ReactNode; - safeAreaBottom: number; } -function MobileComboboxBody(props: MobileBodyProps): ReactElement { +function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { + const active = useRetainedPanelActive(); + if (!active) return null; + // The visibility tracker must leave the tree with its sheet. + return ; +} + +function MobileComboboxSheet(props: MobileBodyProps): ReactElement { + const { + sheetRef: bottomSheetRef, + handleSheetChange, + handleSheetDismiss, + } = useIsolatedBottomSheetVisibility({ + visible: props.isOpen, + onClose: props.onClose, + }); const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); - const { footer, safeAreaBottom } = props; - const frameStyle = useMemo( - () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], - [footer, safeAreaBottom], - ); - const footerStyle = useMemo( - () => [styles.footer, { paddingBottom: safeAreaBottom }], - [safeAreaBottom], - ); - - const footerView = useMemo( - () => (footer ? {footer} : undefined), - [footer, footerStyle], - ); - const body = props.hasChildren ? ( props.children ) : ( @@ -979,13 +975,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { return ( - - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - - + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + ); } +/** + * Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the + * one left where the combobox was opened: inside a sheet tab, none is. + */ +function ComboboxSheetFrame({ footer, children }: { footer?: ReactNode; children: ReactNode }) { + const { bottom: safeAreaBottom } = useSafeAreaInsets(); + const frameStyle = useMemo( + () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], + [footer, safeAreaBottom], + ); + const footerStyle = useMemo( + () => [styles.footer, { paddingBottom: safeAreaBottom }], + [safeAreaBottom], + ); + const footerView = useMemo( + () => (footer ? {footer} : undefined), + [footer, footerStyle], + ); + return ( + + {children} + + ); +} + interface DesktopBodyProps { overlayLayer: number; isOpen: boolean; @@ -1168,7 +1187,8 @@ function DesktopComboboxOptionsBody(props: { ); } -function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { +function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null { + const active = useRetainedPanelActive(); const handleDesktopKey = props.handleDesktopKey; const handleWebOverlayKeyDown = useCallback( (event: KeyboardEvent) => { @@ -1190,6 +1210,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { [props.refs, setWebOverlayScope], ); + // Remove the portal before a retained chat can freeze its closing update. + if (!active) return null; + const overlay = ( { + if (!active && isOpen) handleClose(); + }, [active, isOpen, handleClose]); + useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey); const collisionPadding = useMemo(computeCollisionPadding, []); @@ -1413,16 +1440,6 @@ export function Combobox({ referenceWidth, }); - const { - sheetRef: bottomSheetRef, - handleSheetChange, - handleSheetDismiss, - } = useIsolatedBottomSheetVisibility({ - visible: isOpen, - isEnabled: isMobile, - onClose: handleClose, - }); - const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = useMemo( @@ -1572,10 +1589,8 @@ export function Combobox({ if (isMobile) { return ( {children} @@ -1765,7 +1779,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, desktopOverlayWeb: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, pointerEvents: "auto" as const, }, desktopBackdrop: { diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 86dd7e978b..995caf4a0b 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -6,8 +6,6 @@ import { type Ref, } from "react"; import { - Platform, - StatusBar, View, type GestureResponderEvent, type PressableProps, @@ -133,10 +131,9 @@ export function ContextMenuTrigger({ const point = coerceEventPoint(event); if (!point) return; - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; ctx.setAnchorRect({ x: point.pageX, - y: point.pageY + statusBarHeight, + y: point.pageY, width: 0, height: 0, }); diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 0b0675596f..3544d9f940 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -81,6 +81,8 @@ describe("control geometry", () => { it("keeps field text sizing tied to control size", () => { const geometry = createControlGeometry(theme); + expect(geometry.fieldTextXs.fontSize).toBe(12); + expect(geometry.fieldTextXs.lineHeight).toBe(17); expect(geometry.fieldTextSm.fontSize).toBe(14); expect(geometry.fieldTextSm.lineHeight).toBe(20); expect(geometry.fieldTextMd.fontSize).toBe(14); @@ -95,6 +97,13 @@ describe("control geometry", () => { const geometry = createControlGeometry(theme); const borderWidth = theme.borderWidth[1]; + expect(geometry.fieldControlXs.minHeight).toBe(28); + expect( + geometry.fieldTextXs.lineHeight + + geometry.fieldControlXs.paddingVertical * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlXs.minHeight); + expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.paddingVertical).toBe(5); expect( diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index d56200887e..0a41f80039 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; import { ICON_SIZE, type Theme } from "@/styles/theme"; export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; -export type FieldControlSize = "sm" | "md"; +export type FieldControlSize = "xs" | "sm" | "md"; export type SegmentedControlSize = "xs" | "sm" | "md"; export type ControlInteractionPhase = "rest" | "hover" | "active"; @@ -188,8 +188,19 @@ export function resolveControlInteractionStyles( export function createControlGeometry(theme: Theme) { const controlBorderWidth = theme.borderWidth[1]; + const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); + const fieldControlXs = { + minHeight: CONTROL_HEIGHTS.tight, + paddingHorizontal: theme.spacing[2], + paddingVertical: fieldVerticalPadding( + CONTROL_HEIGHTS.tight, + fieldTextXsLineHeight, + controlBorderWidth, + ), + borderRadius: theme.borderRadius.md, + }; const fieldControlSm = { minHeight: CONTROL_HEIGHTS.compact, paddingHorizontal: theme.spacing[3], @@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) { ), borderRadius: theme.borderRadius.lg, }; + const fieldTextXs = { + fontSize: theme.fontSize.sm, + lineHeight: fieldTextXsLineHeight, + }; const fieldTextSm = { fontSize: theme.fontSize.base, lineHeight: fieldTextSmLineHeight, @@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) { fontSize: theme.fontSize.sm, }, alert, + formTextInputXs: { + ...fieldControlXs, + ...fieldTextXs, + }, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) { ...fieldControlMd, ...fieldTextMd, }, + fieldControlXs, fieldControlSm, fieldControlMd, + fieldTextXs, fieldTextSm, fieldTextMd, controlRest: { diff --git a/packages/app/src/components/ui/control-size.tsx b/packages/app/src/components/ui/control-size.tsx new file mode 100644 index 0000000000..c6bcbbea78 --- /dev/null +++ b/packages/app/src/components/ui/control-size.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type ReactNode } from "react"; + +/** The control sizes a container can ask of the controls placed in it. */ +export type ContainedControlSize = "xs" | "sm"; + +// A row decides how big its controls are, so a button or field placed in it needs no size of +// its own. A size passed to the control still wins. +const ControlSizeContext = createContext(null); + +export function ControlSizeProvider({ + size, + children, +}: { + size: ContainedControlSize; + children: ReactNode; +}) { + return {children}; +} + +/** The size the surrounding container asks for, or null outside one. */ +export function useContainedControlSize(): ContainedControlSize | null { + return useContext(ControlSizeContext); +} diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 2fc1eb26ac..a276e40652 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; +import { useContainedControlSize } from "@/components/ui/control-size"; const ThemedChevronDown = withUnistyles(ChevronDown); @@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit { color: string }> = { + surface3: (theme) => ({ color: theme.colors.surface3 }), surface0: (theme) => ({ color: theme.colors.surface0 }), surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }), }; diff --git a/packages/app/src/components/ui/floating-panel-portal.tsx b/packages/app/src/components/ui/floating-panel-portal.tsx index c9c36375f4..21ea94a03f 100644 --- a/packages/app/src/components/ui/floating-panel-portal.tsx +++ b/packages/app/src/components/ui/floating-panel-portal.tsx @@ -81,6 +81,6 @@ export function FloatingPanelPortalHost({ const styles = StyleSheet.create({ host: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, }); diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index f55b10bb10..349d658e4f 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -23,6 +23,16 @@ import { resolveControlInteractionStyles, type FieldControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; + +const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record< + FieldControlSize, + string +>; +const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record< + FieldControlSize, + string +>; interface FieldProps { label: string; @@ -159,11 +169,13 @@ function assignTextInputRef( } export const FormTextInput = forwardRef( - function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) { + function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) { const [focused, setFocused] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = editable === false; - const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd; - const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd; + const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]]; + const inputSizeStyle = formInputStyles[FIELD_TEXT[size]]; const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const setInputRef = useCallback( (node: EditingTextInputHandle | null) => { @@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => { chrome: { backgroundColor: theme.colors.surface2, }, + chromeXs: { + ...geometry.fieldControlXs, + }, chromeSm: { ...geometry.fieldControlSm, }, @@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => { outlineColor: "transparent", outlineWidth: 0, }, + inputXs: { + ...geometry.fieldTextXs, + }, inputSm: { ...geometry.fieldTextSm, }, diff --git a/packages/app/src/components/ui/highlighted-text-segments.test.ts b/packages/app/src/components/ui/highlighted-text-segments.test.ts index 6f42fe823d..4e23389fde 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.test.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.test.ts @@ -63,7 +63,9 @@ describe("findHighlightRanges", () => { ); }); it("uses the search matcher for typo and scattered-character matches", () => { - expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]"); + expect( + marked("billing", findHighlightRanges("bulling", "billing", { typoTolerant: true })), + ).toBe("[billing]"); expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe( "[pas]eo-[bab]ysit", ); @@ -79,6 +81,9 @@ describe("findHighlightRanges", () => { "check [conf]i[g]uration", ); }); + it("marks typos only when the list was matched typo-tolerantly", () => { + expect(findHighlightRanges("bulling", "billing")).toEqual([]); + }); it("merges overlapping tokens and ignores empty or unmatched queries", () => { expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]"); expect(findHighlightRanges("", "billing")).toEqual([]); diff --git a/packages/app/src/components/ui/highlighted-text-segments.ts b/packages/app/src/components/ui/highlighted-text-segments.ts index 0bd4f1e0bd..f65f6c44eb 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.ts @@ -4,6 +4,7 @@ import { scoreMatch, tokenizeQuery, type MatchRange, + type TextFieldsOptions, } from "@getpaseo/protocol/search/text-match"; export interface HighlightSegment { @@ -40,11 +41,22 @@ export function toHighlightSegments( return segments; } -/** Best-effort presentation of each query token in this field, independently of other fields. */ -export function findHighlightRanges(query: string, text: string): MatchRange[] { +/** + * Best-effort presentation of each query token in this field, independently of other fields. + * Pass the same options the list was matched with, so a mark never claims a match the list did + * not make. + */ +export function findHighlightRanges( + query: string, + text: string, + options: TextFieldsOptions = {}, +): MatchRange[] { const ranges = tokenizeQuery(query) .flatMap((token) => { - const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) }); + const score = scoreMatch(token, text, { + fuzzy: options.typoTolerant ? fuzzyPolicyForToken(token) : null, + subsequence: options.subsequence, + }); return score ? matchRanges(token, text, score) : []; }) .sort((left, right) => left.start - right.start); diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index 3d438505d3..fd81294f80 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -14,13 +14,16 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { Portal } from "@gorhom/portal"; -import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { OverlayLayerProvider, useOverlayLayer, useWebOverlayRegistration, } from "@/lib/overlay-root"; import { FloatingSurface } from "@/components/ui/floating"; +import { + measureFloatingPanelPortalHost, + useFloatingPanelPortalHostName, +} from "@/components/ui/floating-panel-portal"; import { isWeb } from "@/constants/platform"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; import { @@ -326,28 +329,31 @@ function HoverCardSurface({ triggerRef: RefObject; contentRef: RefObject; }): ReactElement { - const bottomSheetInternal = useBottomSheetModalInternal(true); - const [triggerRect, setTriggerRect] = useState(null); + const portalHostName = useFloatingPanelPortalHostName(); + const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null); const [contentSize, setContentSize] = useState(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); useEffect(() => { if (!triggerRef.current) return; let cancelled = false; - void measureElement(triggerRef.current).then((rect) => { - if (!cancelled) setTriggerRect(rect); + void Promise.all([ + measureElement(triggerRef.current), + measureFloatingPanelPortalHost(portalHostName), + ]).then(([triggerRect, hostRect]) => { + if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect }); return undefined; }); return () => { cancelled = true; }; - }, [triggerRef]); + }, [portalHostName, triggerRef]); useEffect(() => { - if (!triggerRect || !contentSize) return; + if (!anchor || !contentSize) return; const { width, height } = Dimensions.get("window"); const { x, y } = computePosition({ - triggerRect, + triggerRect: anchor.triggerRect, contentSize, displayArea: { x: 0, y: 0, width, height }, placement, @@ -355,8 +361,8 @@ function HoverCardSurface({ alignment, offset, }); - setPosition({ x, y }); - }, [alignment, contentSize, offset, placement, triggerRect]); + setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y }); + }, [alignment, anchor, contentSize, offset, placement]); const handleLayout = useCallback( (event: { nativeEvent: { layout: { width: number; height: number } } }) => { @@ -377,7 +383,7 @@ function HoverCardSurface({ const surfaceStyle = useMemo(() => [styles.surface, style], [style]); return ( - + ({ + color: theme.colors.foreground, +}); + export const mutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx index d107547dcf..6990f84902 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx @@ -32,9 +32,12 @@ import { useSharedValue } from "react-native-reanimated"; export function SheetHostScope({ children, style, + enabled = true, }: { children: ReactNode; style?: StyleProp; + /** Disabled scopes keep their mounted children and use the enclosing host. */ + enabled?: boolean; }) { const parent = useBottomSheetModalInternal(); const hostName = `sheet-host-${useId()}`; @@ -69,12 +72,14 @@ export function SheetHostScope({ ); return ( - + {children} - - - + {enabled ? ( + + + + ) : null} ); @@ -86,6 +91,7 @@ const styles = StyleSheet.create({ minHeight: 0, }, host: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, + zIndex: 1, }, }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index cf820ac86c..6637c5e339 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { }); import { IsolatedBottomSheetModal, type ContextBridge } from "."; +import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout"; import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; /** Renders whatever the portal is currently holding, from its own place in the tree. */ @@ -200,3 +201,35 @@ describe("IsolatedBottomSheetModal context bridging", () => { ).toEqual(["false", "true"]); }); }); + +describe("bottom sheet layout subscription", () => { + it("stops updating a dismissed sheet when the window resizes", () => { + const container = document.createElement("div"); + const root = createRoot(container); + let layout: ReturnType | undefined; + const viewport = document.documentElement; + const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth"); + function resize(width: number) { + Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width }); + act(() => window.dispatchEvent(new Event("resize"))); + } + function LayoutProbe() { + layout = useAnimatedLayout(undefined, 0, 0); + return null; + } + try { + act(() => root.render()); + if (!layout) throw new Error("Sheet layout did not mount"); + const mountedLayout = layout; + resize(640); + expect(mountedLayout.get().window.width).toBe(640); + act(() => root.render(null)); + resize(960); + expect(mountedLayout.get().window.width).toBe(640); + } finally { + act(() => root.unmount()); + if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth); + else Reflect.deleteProperty(viewport, "clientWidth"); + } + }); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 457181c53f..639804bd53 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -123,19 +123,25 @@ export function useIsolatedBottomSheetVisibility({ visible, isEnabled, onClose, + onBack, }: { visible: boolean; isEnabled?: boolean; onClose: () => void; + /** Offered Android Back before it closes the sheet; returns true when it handled the press. */ + onBack?: () => boolean; }) { const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + const onBackRef = useRef(onBack); + onBackRef.current = onBack; const tracker = useMemo( () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current(), backPress: systemBackPress, + onBack: () => onBackRef.current?.() ?? false, }), [], ); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts index 60b386bd45..5205fb2486 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -204,6 +204,31 @@ describe("bottom sheet visibility tracker", () => { expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); }); + it("lets the sheet step back through its own pages before Back dismisses it", () => { + const sheet = new FakeBottomSheet(); + const backPress = new FakeBackPress(); + let openPages = 1; + const tracker = createBottomSheetVisibilityTracker({ + onClose: () => {}, + onBack: () => { + if (openPages === 0) return false; + openPages -= 1; + return true; + }, + backPress, + }); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + tracker.handleSheetIndexChange(0); + + expect(backPress.press()).toBe(true); + expect(openPages).toBe(0); + expect(sheet.events).toEqual([{ type: "present" }]); + + expect(backPress.press()).toBe(true); + expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); + }); + it("leaves Back to the navigator before the sheet is on screen and after it closes", () => { const context = setup(); context.tracker.attachController(context.sheet); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts index dec2dd535c..167ed2ebe6 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -36,6 +36,11 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing"; export function createBottomSheetVisibilityTracker(opts: { onClose: () => void; backPress?: BackPressSource; + /** + * Offered the Back press before the sheet takes it as a close. Returns true when it handled + * the press itself, such as stepping back a page inside the sheet. + */ + onBack?: () => boolean; }): BottomSheetVisibilityTracker { let controller: BottomSheetController | null = null; let visible = false; @@ -60,6 +65,7 @@ export function createBottomSheetVisibilityTracker(opts: { } releaseBackPress = opts.backPress?.subscribe(() => { + if (opts.onBack?.()) return true; dismiss(); return true; }) ?? null; diff --git a/packages/app/src/components/ui/menu/index.ts b/packages/app/src/components/ui/menu/index.ts index 5668556899..437090d146 100644 --- a/packages/app/src/components/ui/menu/index.ts +++ b/packages/app/src/components/ui/menu/index.ts @@ -12,6 +12,8 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f export { MenuSurface, useMenuSurface, + type MenuFillPageDefinition, + type MenuListPageDefinition, type MenuPageDefinition, type MenuSurfaceProps, } from "./menu-surface"; diff --git a/packages/app/src/components/ui/menu/menu-context.test.tsx b/packages/app/src/components/ui/menu/menu-context.test.tsx index 40b45b9343..5640b7ef82 100644 --- a/packages/app/src/components/ui/menu/menu-context.test.tsx +++ b/packages/app/src/components/ui/menu/menu-context.test.tsx @@ -61,3 +61,38 @@ describe("useMenuState selectItem elsewhere", () => { expect(onSelect).toHaveBeenCalledTimes(1); }); }); + +describe("useMenuState pages", () => { + it("pops the open page on Back and leaves Back at the root to the surface", () => { + const { result } = renderHook(() => useMenuState({ defaultOpen: true })); + act(() => result.current.openSub({ id: "row", depth: 0 })); + + let handled = false; + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(true); + expect(result.current.path).toEqual([]); + + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(false); + expect(result.current.open).toBe(true); + }); + + it("falls back to the list when the open page's definition goes away", () => { + const { result } = renderHook(() => useMenuState({ defaultOpen: true })); + act(() => result.current.openSub({ id: "row", depth: 0 })); + + const keepNoPage = () => false; + act(() => result.current.prunePages(keepNoPage)); + + expect(result.current.path).toEqual([]); + let handled = true; + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(false); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-context.ts b/packages/app/src/components/ui/menu/menu-context.ts index 9635f09c9f..502580c308 100644 --- a/packages/app/src/components/ui/menu/menu-context.ts +++ b/packages/app/src/components/ui/menu/menu-context.ts @@ -7,6 +7,7 @@ import { goBack as goBackPath, MENU_ROOT_PATH, openSubPage, + pruneMissingPages, type MenuPath, } from "./menu-navigation"; import type { Rect } from "../anchor"; @@ -40,7 +41,10 @@ export interface MenuContextValue { path: MenuPath; openSub: (sub: { id: string; depth: number }) => void; closeSub: (depth: number) => void; - goBack: () => void; + /** Pops the open page. False at the root, where Back is the surface's to answer. */ + goBack: () => boolean; + /** Closes any open page that no longer has a definition, falling back to its parent. */ + prunePages: (hasPage: (id: string) => boolean) => void; } const MenuContext = createContext(null); @@ -160,7 +164,13 @@ export function useMenuState({ }, []); const goBack = useCallback(() => { + if (path.length === 0) return false; setPath((current) => goBackPath(current)); + return true; + }, [path.length]); + + const prunePages = useCallback((hasPage: (id: string) => boolean) => { + setPath((current) => pruneMissingPages(current, hasPage)); }, []); return useMemo( @@ -176,7 +186,19 @@ export function useMenuState({ openSub, closeSub, goBack, + prunePages, }), - [isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack], + [ + isOpen, + setOpen, + selectItem, + anchorRect, + presentation, + path, + openSub, + closeSub, + goBack, + prunePages, + ], ); } diff --git a/packages/app/src/components/ui/menu/menu-navigation.test.ts b/packages/app/src/components/ui/menu/menu-navigation.test.ts index 4b447ef3f2..9740f3df52 100644 --- a/packages/app/src/components/ui/menu/menu-navigation.test.ts +++ b/packages/app/src/components/ui/menu/menu-navigation.test.ts @@ -6,6 +6,7 @@ import { isSubPageOpen, MENU_ROOT_PATH, openSubPage, + pruneMissingPages, } from "./menu-navigation"; describe("openSubPage", () => { @@ -73,3 +74,15 @@ describe("isSubPageOpen", () => { expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false); }); }); + +describe("pruneMissingPages", () => { + it("falls back to the deepest page that still exists", () => { + expect(pruneMissingPages(["list", "row"], (id) => id === "list")).toEqual(["list"]); + expect(pruneMissingPages(["list", "row"], (id) => id === "row")).toEqual([]); + }); + + it("returns the same path when every page exists", () => { + const path = ["list", "row"]; + expect(pruneMissingPages(path, () => true)).toBe(path); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-navigation.ts b/packages/app/src/components/ui/menu/menu-navigation.ts index 2cccca9ed1..b38f9acf13 100644 --- a/packages/app/src/components/ui/menu/menu-navigation.ts +++ b/packages/app/src/components/ui/menu/menu-navigation.ts @@ -33,6 +33,16 @@ export function closeSubPage(path: MenuPath, depth: number): MenuPath { return path.slice(0, depth); } +/** + * Cuts the path back to the deepest page that still has a definition. A page can lose its + * definition while it is open (a subagent's page goes with its row when the subagent is + * archived), and the pages it opened go with it. + */ +export function pruneMissingPages(path: MenuPath, hasPage: (id: string) => boolean): MenuPath { + const missing = path.findIndex((id) => !hasPage(id)); + return missing === -1 ? path : path.slice(0, missing); +} + export function goBack(path: MenuPath): MenuPath { if (path.length === 0) return path; return path.slice(0, -1); diff --git a/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx new file mode 100644 index 0000000000..9d60353a52 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx @@ -0,0 +1,124 @@ +import React, { act, useEffect, useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Text, View } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; +import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +/** + * A real browser, because the regression lives in Reanimated's web entering path: it snapshots + * the surface's rect when a custom keyframe ends and writes that rect back as inline + * top/left/width/height when it cleans the keyframe up, 750ms after the animation starts. + * Anything that moved or resized the surface in between is undone by that write. + */ + +interface Mounted { + root: Root; + container: HTMLDivElement; +} + +const mounted: Mounted[] = []; + +function mount(node: ReactNode): void { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + mounted.push({ root, container }); +} + +afterEach(() => { + for (const entry of mounted.splice(0)) { + act(() => entry.root.unmount()); + entry.container.remove(); + } +}); + +const OFFSET = 12; +const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const; +const SHORT: Size = { width: 200, height: 40 }; +const TALL: Size = { width: 200, height: 240 }; +const WIDE: Size = { width: 480, height: 40 }; +/** Inside the window between the entering animation ending and Reanimated's cleanup. */ +const GROWS_BEFORE_CLEANUP_MS = 400; +/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */ +const GROWS_AFTER_CLEANUP_MS = 1_000; +/** Long enough after the content grew for anything that would write to the surface to land. */ +const SETTLE_MS = 1_200; + +/** Plain timers, not `act`: an async `act` holds every update until it resolves. */ +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`); + return found; +} + +/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */ +function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) { + const [grown, setGrown] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setGrown(true), afterMs); + return () => clearTimeout(timer); + }, [afterMs]); + return ; +} + +function mountMenuAbovePill(body: ReactNode): void { + mount( + + + + Open panel + + + {body} + + + , + ); + act(() => element("pill").click()); +} + +describe("anchored menu surface on web", () => { + beforeEach(async () => { + await page.viewport(1280, 900); + }); + + it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])( + "stays on its anchor when its content grows %ims after opening", + async (afterMs) => { + mountMenuAbovePill(); + + await wait(afterMs + SETTLE_MS); + + const pill = element("pill").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.height).toBeGreaterThan(240); + expect(panel.bottom).toBeLessThanOrEqual(pill.top); + }, + ); + + it("widens when its content widens after the entering animation", async () => { + mountMenuAbovePill(); + + await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS); + + const body = element("body").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.width).toBeGreaterThanOrEqual(body.width); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 52afc71a77..3979c5b633 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -1,7 +1,9 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useState, type ReactElement, @@ -11,10 +13,8 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Dimensions, - Modal, Platform, Pressable, - StatusBar, View, type StyleProp, type ViewStyle, @@ -48,56 +48,41 @@ const contentEntering = new Keyframe({ 100: { opacity: 1, transform: [{ scale: 1 }] }, }).duration(CONTENT_ENTERING_DURATION_MS); +/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */ +const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = { + opacity: [0, 1], + transform: ["scale(0.97)", "scale(1)"], +}; + +/** Web plays the same keyframes itself; see `useWebContentEntering`. */ +const nativeContentEntering = isWeb ? undefined : contentEntering; + const contentExiting = new Keyframe({ 0: { opacity: 1, transform: [{ scale: 1 }] }, 100: { opacity: 0, transform: [{ scale: 0.97 }] }, }).duration(100); -function releaseFixedMenuHeight(surfaceNativeID: string): void { - if (!isWeb) return; - document.getElementById(surfaceNativeID)?.style.removeProperty("height"); -} - /** - * Reanimated's web entering animation leaves an inline height snapshot on the measured surface. - * Once the menu is open, height must return to content-sized so rows can grow in place — and so - * a submenu page taller than the page it replaced is not clipped to the old height. - * - * `revision` is what makes that second case work: bump it whenever the rendered content changes - * identity (a page push, for instance) and the snapshot is released again. + * On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's + * web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe + * up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that + * moved or resized in between — content that grew once its data arrived — snapped back to the old + * rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style. */ -function useReleaseFixedMenuHeight({ - contentSize, - enabled, +function useWebContentEntering({ + placed, surfaceNativeID, - revision, }: { - contentSize: Size | null; - enabled: boolean; + placed: boolean; surfaceNativeID: string; - revision?: string | number; }): void { - useEffect(() => { - if (!enabled) return undefined; - - const release = () => { - releaseFixedMenuHeight(surfaceNativeID); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID)); - } - }; - const timers: ReturnType[] = [ - setTimeout(release, CONTENT_ENTERING_DURATION_MS), - ]; - - if (contentSize) { - timers.push(setTimeout(release, 0)); - } - - return () => { - for (const timer of timers) clearTimeout(timer); - }; - }, [contentSize, enabled, surfaceNativeID, revision]); + useLayoutEffect(() => { + if (!isWeb || !placed || typeof document === "undefined") return undefined; + const animation = document + .getElementById(surfaceNativeID) + ?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS); + return () => animation?.cancel(); + }, [placed, surfaceNativeID]); } /** @@ -162,16 +147,13 @@ export function useAnchoredPosition({ return undefined; } - // Capture status bar height synchronously before async measurement, so it cannot change - // or read back null between the measure call and its resolution. - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; + // The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one + // coordinate space that starts at the screen top; no status bar offset applies. void measureElement(anchorRef.current).then((rect) => { if (cancelled) return undefined; - // On Android with statusBarTranslucent, measureInWindow returns coordinates relative to - // below the status bar while Modal content starts at the screen top. - setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + setTriggerRect(rect); return undefined; }); @@ -228,8 +210,6 @@ export interface AnchoredSurfaceProps { fullWidth?: boolean; horizontalPadding?: number; scrollable?: boolean; - /** Bump when the rendered content changes identity, so the height snapshot is released. */ - revision?: string | number; /** A submenu sits inside its parent's overlay and must not paint a second backdrop. */ backdrop?: boolean; /** @@ -263,7 +243,6 @@ export function AnchoredSurface({ fullWidth = false, horizontalPadding = 16, scrollable = false, - revision, backdrop = true, onPointerEnter, onPointerLeave, @@ -273,33 +252,25 @@ export function AnchoredSurface({ }: AnchoredSurfaceProps): ReactElement | null { const { t } = useTranslation(); const surfaceNativeID = useId(); - const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } = - useAnchoredPosition({ - open, - anchorRect, - anchorRef, - side, - align, - offset, - scrollable, - maxHeight, - }); - - useReleaseFixedMenuHeight({ - contentSize, - enabled: open, - surfaceNativeID, - revision, + const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({ + open, + anchorRect, + anchorRef, + side, + align, + offset, + scrollable, + maxHeight, }); // The surface is placed once both measurements land. Until then it sits off-screen so it can - // be measured, and it must not animate: Reanimated's web entering path snapshots the element's - // rect when the animation ends and writes that rect back as inline top/left when it cleans the - // keyframe up 750ms later. If the animation ends before placement, the snapshot is the - // off-screen rect and the menu jumps back off-screen. Remounting on placement starts the - // animation from the final position. + // be measured, and it must not animate: an entering animation that ran there would be over + // before anyone saw the menu. Remounting on placement starts the native animation from the + // final position; web starts its own animation on placement. const placed = position !== null; + useWebContentEntering({ placed, surfaceNativeID }); + useEffect(() => { if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; const frame = requestAnimationFrame(() => { @@ -387,7 +358,7 @@ export function AnchoredSurface({ dataSet={surfaceDataSet} style={styles.content} frameStyle={frameStyle} - entering={placed ? contentEntering : undefined} + entering={placed ? nativeContentEntering : undefined} exiting={ !placed || isWeb || !onExited ? undefined @@ -527,7 +498,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, overlayWeb: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, pointerEvents: "auto" as const, }, backdrop: { diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 2f73149244..581b620499 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -19,6 +19,11 @@ import { useIsolatedBottomSheetVisibility, type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + FULL_SHEET_SNAP_POINTS, + SheetBackground, + SheetHandle, +} from "@/components/adaptive-modal-sheet"; import { SPACING, type Theme } from "@/styles/theme"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useMenuContext, MenuContextProvider } from "./menu-context"; @@ -32,24 +37,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not -// track, so the sheet is wrapped rather than reading the theme through a hook. -// See docs/unistyles.md. -const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ - backgroundStyle: { - borderTopLeftRadius: theme.borderRadius.xl, - borderTopRightRadius: theme.borderRadius.xl, - borderWidth: 1, - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - }, - handleIndicatorStyle: { - width: 36, - height: 4, - backgroundColor: theme.colors.surface2, - }, -})); - /** How long the pointer must rest on a submenu row before its flyout opens. */ const HOVER_OPEN_DELAY_MS = 90; /** @@ -65,7 +52,7 @@ const HOVER_CLOSE_GRACE_MS = 260; */ const SUBMENU_OVERLAP = 5; -export interface MenuPageDefinition { +export interface MenuListPageDefinition { id: string; title: string; content: ReactNode; @@ -78,6 +65,24 @@ export interface MenuPageDefinition { * surface stops closing on hover, since its parent leads back to the same dismissal. */ hoverIntent?: boolean; + fill?: false; +} + +/** + * A sheet page that takes the sheet's full height and draws everything itself, header included: + * no back header, no scroller, no content panning. Only the sheet presentation opens one; a + * popover never has a trigger for it. + */ +export interface MenuFillPageDefinition { + id: string; + fill: true; + content: ReactNode; +} + +export type MenuPageDefinition = MenuListPageDefinition | MenuFillPageDefinition; + +function pageHoverIntent(page: MenuPageDefinition | undefined): boolean { + return !page || page.fill === true || page.hoverIntent !== false; } interface MenuSurfaceContextValue { @@ -126,8 +131,15 @@ export interface MenuSurfaceProps { * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { - const { presentation, open, setOpen } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // An open page whose definition went away (its row was removed) falls back to its parent, so + // neither a render nor a later Back walks a path to a page that no longer exists. + const { pages } = props; + useEffect(() => { + if (path.length === 0) return; + prunePages((id) => pages?.some((page) => page.id === id) ?? false); + }, [pages, path, prunePages]); // A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes // for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet // and promoted it to a tab, say, would otherwise bring the list back on the next tab switch. @@ -234,11 +246,11 @@ function MenuPopoverSurface({ // `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on // its trigger, and while it is open nothing on this surface closes on a pointer leaving it. const hoverValue = useMemo(() => { - const locked = openPages.some(({ page }) => page.hoverIntent === false); + const locked = openPages.some(({ page }) => !pageHoverIntent(page)); return { ...surfaceValue, hoverOpen: (sub) => { - if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return; + if (!pageHoverIntent(pages.find((page) => page.id === sub.id))) return; surfaceValue.hoverOpen(sub); }, hoverClose: (depth) => { @@ -332,7 +344,6 @@ function MenuFlyout({ maxHeight={maxHeight} scrollable={scrollable} backdrop={false} - revision={page.id} onPointerEnter={cancelHoverClose} onPointerLeave={handleHoverOut} testID={testID} @@ -353,17 +364,6 @@ function MenuSheetSurface({ const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); - const safeAreaInsets = useSafeAreaInsets(); - - const sheetScrollContentStyle = useMemo( - () => ({ - paddingBottom: getMenuSheetBottomPadding({ - safeAreaBottom: safeAreaInsets.bottom, - breathingRoom: SPACING[1], - }), - }), - [safeAreaInsets.bottom], - ); const sheetDataSet = useMemo( () => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined), [keyboardFocusScope], @@ -374,11 +374,15 @@ function MenuSheetSurface({ visible: menu.open, isEnabled: true, onClose: handleClose, + // Android Back steps back through the pages before it closes the sheet. + onBack: menu.goBack, }); const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; + const fillPage = openPage?.fill === true ? openPage : null; + const listPage = openPage?.fill === true ? null : openPage; // The sheet's content is teleported out of this subtree, so both menu contexts have to be // rebuilt on the other side. Providing them around the modal instead would put them on the @@ -393,12 +397,18 @@ function MenuSheetSurface({ ); return ( - - - {openPage ? ( - <> - - {openPage.content} - - ) : ( - <> - {sheetTitle ? ( - - ) : null} - {children} - - )} - - + {fillPage ? ( + + {fillPage.content} + + ) : ( + + {listPage ? ( + <> + + {listPage.content} + + ) : ( + <> + {sheetTitle ? ( + + ) : null} + {children} + + )} + + )} + + ); +} + +/** + * Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the + * one left where the menu was opened: inside a sheet tab, none is. + */ +function MenuSheetScrollView({ + dataSet, + testID, + children, +}: { + dataSet: { keyboardScope: KeyboardFocusScope } | undefined; + testID?: string; + children: ReactNode; +}) { + const safeAreaInsets = useSafeAreaInsets(); + const contentStyle = useMemo( + () => ({ + paddingBottom: getMenuSheetBottomPadding({ + safeAreaBottom: safeAreaInsets.bottom, + breathingRoom: SPACING[1], + }), + }), + [safeAreaInsets.bottom], + ); + return ( + + {children} + ); } @@ -470,6 +516,10 @@ function MenuSheetHeader({ } const styles = StyleSheet.create((theme) => ({ + fillPage: { + flex: 1, + minHeight: 0, + }, sheetHeader: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/ui/modal.tsx b/packages/app/src/components/ui/modal.tsx new file mode 100644 index 0000000000..884377fd8c --- /dev/null +++ b/packages/app/src/components/ui/modal.tsx @@ -0,0 +1,13 @@ +import { Modal as NativeModal, type ModalProps } from "react-native"; + +// React Native defaults iPhone modals to portrait even when the app supports rotation. +const orientations: NonNullable = [ + "portrait", + "portrait-upside-down", + "landscape-left", + "landscape-right", +]; + +export function Modal(props: ModalProps) { + return ; +} diff --git a/packages/app/src/components/ui/press-highlight.native.tsx b/packages/app/src/components/ui/press-highlight.native.tsx index 858e68843f..e1802dd689 100644 --- a/packages/app/src/components/ui/press-highlight.native.tsx +++ b/packages/app/src/components/ui/press-highlight.native.tsx @@ -66,9 +66,9 @@ export const PressHighlight = forwardRef(function Pre const styles = StyleSheet.create({ highlight: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, highlightFill: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, }); diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index e0bbdf3f7b..d828c26c4d 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -7,6 +7,7 @@ import { segmentedIconSize, type SegmentedControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; import type { Theme } from "@/styles/theme"; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; @@ -48,11 +49,13 @@ export function SegmentedControl({ options, value, onValueChange, - size = "md", + size: sizeProp, hideLabels = false, style, testID, }: SegmentedControlProps) { + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const sizeStyles = { xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs }, sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm }, diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index d53788cb00..6be0cf353e 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -68,7 +68,7 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 0, }, badgeBackground: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface3, opacity: theme.opacity[50], diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 72803f06d0..99f446001f 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { Children, cloneElement, @@ -16,9 +17,7 @@ import { createPortal } from "react-dom"; import { Dimensions, Platform, - Modal, Pressable, - StatusBar, View, type PressableProps, type StyleProp, @@ -28,6 +27,7 @@ import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { FloatingSurface } from "@/components/ui/floating"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { isNative, isWeb } from "@/constants/platform"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; @@ -257,8 +257,15 @@ export function Tooltip({ }); const isCompact = useIsCompactFormFactor(); + const active = useRetainedPanelActive(); const opensOnPress = isNative || isCompact; - const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop; + const enabled = active && (opensOnPress ? enabledOnMobile : enabledOnDesktop); + + // Remove the portal in the inactive commit, before the retained chat freezes. + // Closing also prevents the tooltip from returning when its panel is shown again. + useEffect(() => { + if (!active && isOpen) setIsOpen(false); + }, [active, isOpen, setIsOpen]); const value = useMemo( () => ({ @@ -330,6 +337,10 @@ export function TooltipTrigger({ }; }, [clearOpenTimer]); + useEffect(() => { + if (!ctx.enabled) clearOpenTimer(); + }, [ctx.enabled, clearOpenTimer]); + const handleHoverIn = useCallback( (e?: unknown) => { if (isCallable(onHoverIn)) onHoverIn(e); @@ -474,11 +485,10 @@ export function TooltipContent({ return () => {}; } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; void measureElement(ctx.triggerRef.current).then((rect) => { - if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + if (!cancelled) setTriggerRect(rect); return undefined; }); diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx new file mode 100644 index 0000000000..e7bef2d177 --- /dev/null +++ b/packages/app/src/components/user-message/index.tsx @@ -0,0 +1,100 @@ +import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { View, type LayoutChangeEvent, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { Button } from "@/components/ui/button"; +import { EdgeFade } from "@/components/ui/edge-fade"; +import { useProviderIcon } from "@/components/provider-icons"; +import { useSessionStore } from "@/stores/session-store"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { isNative } from "@/constants/platform"; + +const PREVIEW_HEIGHT = 240; +// Native View still clips with "scroll", but Yoga measures its children without +// the height cap. "hidden" caps the measured body too, hiding the expand control. +const previewStyle: ViewStyle = { + maxHeight: PREVIEW_HEIGHT, + overflow: isNative ? "scroll" : "hidden", +}; + +export function MessagePreview({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + const measure = useCallback( + (event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT), + [], + ); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + return ( + <> + + + {children} + + {overflowing && !expanded ? ( + + ) : null} + + {overflowing ? ( + + ) : null} + + ); +} + +export function MessageSender({ + sender, + serverId, +}: { + sender: AgentMessage["sender"]; + serverId?: string; +}) { + const { t } = useTranslation(); + const host = sender.serverId ?? serverId; + const agent = useSessionStore((state) => { + const session = host ? state.sessions[host] : undefined; + return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id); + }); + const ProviderIcon = useProviderIcon(agent?.provider ?? "", host); + const renderProviderIcon = useCallback( + (color: string) => , + [ProviderIcon], + ); + const openSender = useCallback(() => { + if (host) navigateToAgent({ serverId: host, agentId: sender.id }); + }, [host, sender.id]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { flexShrink: 0 }, + sender: { paddingRight: 0, borderRightWidth: 0, maxWidth: "100%" }, + senderText: { flexShrink: 1 }, + toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 }, + expandedToggle: { marginTop: theme.spacing[2] }, + fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] }, +})); diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 1b3cdbe237..cea9359376 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useAddHost } from "@/hosts/add-host"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; @@ -12,11 +13,7 @@ import { Terminal, } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import type { HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; -import { AddHostModal } from "./add-host-modal"; -import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal"; -import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null { ); } -export interface WelcomeScreenProps { - onHostAdded?: (profile: HostProfile) => void; -} - -export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { +export function WelcomeScreen() { const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); - const [isDirectOpen, setIsDirectOpen] = useState(false); - const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false); - const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); const hosts = useHosts(); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); @@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { router.push("/settings"); }, [router]); - const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []); - const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []); - const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []); - const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []); - const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []); - const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []); - const handleScanQr = useCallback(() => { - router.push("/pair-scan?source=onboarding"); - }, [router]); - - const handleHostSaved = useCallback( - ({ profile }: { profile: HostProfile; serverId: string }) => { - onHostAdded?.(profile); - finishOnboarding(); - }, - [onHostAdded, finishOnboarding], - ); + const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding }); + const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding }); + const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding }); + const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding }); const actions: WelcomeAction[] = isWeb || isFdroidBuild @@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { {appVersionText} - - - - - - ); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 7124fd2f0a..84e9141b6a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -31,6 +31,8 @@ function workspaceDescriptor(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); useSidebarViewStore.setState({ groupMode: "project", diff --git a/packages/app/src/components/workspace-tab-drag-context.ts b/packages/app/src/components/workspace-tab-drag-context.ts new file mode 100644 index 0000000000..543997af13 --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; +import type { TabDropPreview } from "./split-container-tab-drop-preview"; +import type { SplitDropZoneHover } from "./split-drop-zone"; +export const WorkspaceTabDragContext = createContext<{ + activeDragTabId: string | null; + dropPreview: SplitDropZoneHover | null; + tabDropPreview: TabDropPreview | null; + externalDndContext: boolean; +}>({ activeDragTabId: null, dropPreview: null, tabDropPreview: null, externalDndContext: false }); +export function useWorkspaceTabDrag() { + return useContext(WorkspaceTabDragContext); +} diff --git a/packages/app/src/components/workspace-tab-drag-policy.ts b/packages/app/src/components/workspace-tab-drag-policy.ts new file mode 100644 index 0000000000..e2ac7a2dee --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-policy.ts @@ -0,0 +1,9 @@ +export function acceptsWorkspaceTabDrop( + allowCrossPane: boolean, + sourcePaneId: string, + destination: { kind: "workspace-tab" | "split-pane-drop"; paneId: string }, +): boolean { + return ( + allowCrossPane || (destination.kind === "workspace-tab" && sourcePaneId === destination.paneId) + ); +} diff --git a/packages/app/src/components/workspace-tab-drag-scope.tsx b/packages/app/src/components/workspace-tab-drag-scope.tsx new file mode 100644 index 0000000000..4c3f077632 --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-scope.tsx @@ -0,0 +1,5 @@ +import type { ComponentProps } from "react"; +import type { WorkspaceTabDragScope as WebScope } from "./workspace-tab-drag-scope.web"; +export function WorkspaceTabDragScope({ children }: ComponentProps) { + return children; +} diff --git a/packages/app/src/components/workspace-tab-drag-scope.web.tsx b/packages/app/src/components/workspace-tab-drag-scope.web.tsx new file mode 100644 index 0000000000..3ff9bc3b10 --- /dev/null +++ b/packages/app/src/components/workspace-tab-drag-scope.web.tsx @@ -0,0 +1,494 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { View, Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + pointerWithin, + useSensor, + useSensors, + type CollisionDetection, + type DragEndEvent, + type DragMoveEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { computeTabDropPreview, type TabDropPreview } from "./split-container-tab-drop-preview"; +import { resolveSplitDropPosition, type SplitDropZoneHover } from "./split-drop-zone"; +import { getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabIcon, +} from "@/screens/workspace/workspace-tab-presentation"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + collectAllPanes, + createWorkspaceLayoutWithExplorerSidebar, + type WorkspaceLayout, + type SplitPane, +} from "@/stores/workspace-layout-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; +import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; +import { acceptsWorkspaceTabDrop } from "./workspace-tab-drag-policy"; +import { WorkspaceTabDragContext } from "./workspace-tab-drag-context"; +interface WorkspaceTabDragData { + kind: "workspace-tab"; + paneId: string; + tabId: string; +} + +interface SplitPaneDropData { + kind: "split-pane-drop"; + paneId: string; +} + +function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { + return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; +} + +function isSplitPaneDropData(data: unknown): data is SplitPaneDropData { + return ( + typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop" + ); +} + +function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined { + return isWorkspaceTabDragData(data) ? data : undefined; +} + +function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined { + if (isWorkspaceTabDragData(data)) return data; + if (isSplitPaneDropData(data)) return data; + return undefined; +} + +interface DragMoveRects { + translatedRect: { left: number; top: number; width: number; height: number }; + overRect: { left: number; top: number; width: number; height: number }; +} + +function resolveDragMoveRects( + event: Pick | Pick, +): DragMoveRects | null { + const translatedRect = event.active.rect.current.translated; + const overRect = event.over?.rect; + if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { + return null; + } + return { translatedRect, overRect }; +} + +function computeTabOverDropPreview(input: { + activeData: WorkspaceTabDragData; + overData: WorkspaceTabDragData; + rects: DragMoveRects; + panesById: Map; + uiTabs: WorkspaceTab[]; +}): TabDropPreview | null { + const { activeData, overData, rects, panesById, uiTabs } = input; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!targetPane) { + return null; + } + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); + return computeTabDropPreview({ + activePaneId: activeData.paneId, + activeTabId: activeData.tabId, + overPaneId: overData.paneId, + overTabId: overData.tabId, + targetTabs, + activeRect: { + left: rects.translatedRect.left, + width: rects.translatedRect.width, + }, + overRect: { + left: rects.overRect.left, + width: rects.overRect.width, + }, + }); +} + +function computePaneOverDropPreview(input: { + overData: SplitPaneDropData; + rects: DragMoveRects; +}): SplitDropZoneHover | null { + const { overData, rects } = input; + const centerX = rects.translatedRect.left + rects.translatedRect.width / 2; + const centerY = rects.translatedRect.top + rects.translatedRect.height / 2; + const relativeX = centerX - rects.overRect.left; + const relativeY = centerY - rects.overRect.top; + if ( + Number.isNaN(relativeX) || + Number.isNaN(relativeY) || + relativeX < 0 || + relativeX > rects.overRect.width || + relativeY < 0 || + relativeY > rects.overRect.height + ) { + return null; + } + return { + paneId: overData.paneId, + position: resolveSplitDropPosition({ + width: rects.overRect.width, + height: rects.overRect.height, + x: relativeX, + y: relativeY, + }), + }; +} + +const dropCollisionDetection: CollisionDetection = (args) => { + const pointerHits = pointerWithin(args); + const tabHits = pointerHits.filter( + (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab", + ); + if (tabHits.length > 0) { + return tabHits; + } + + const paneHits = pointerHits.filter( + (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop", + ); + if (paneHits.length > 0) { + return paneHits; + } + + return closestCenter(args); +}; + +const EMPTY_LAYOUT = createWorkspaceLayoutWithExplorerSidebar(); + +interface WorkspaceTabDragScopeProps { + children: ReactNode; + layout: WorkspaceLayout | null; + explorerSidebarPaneId: string | null; + allowCrossPane: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + uiTabs: WorkspaceTab[]; + onMoveTabToPane: (tabId: string, paneId: string) => void; + onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; + onSplitPane: (input: { + tabId: string; + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + }) => void; +} +export function WorkspaceTabDragScope({ + children, + layout, + explorerSidebarPaneId, + allowCrossPane, + normalizedServerId, + normalizedWorkspaceId, + uiTabs, + onMoveTabToPane, + onReorderTabsInPane, + onSplitPane, +}: WorkspaceTabDragScopeProps) { + const [activeDragTabId, setActiveDragTabId] = useState(null); + const [dropPreview, setDropPreview] = useState(null); + const [tabDropPreview, setTabDropPreview] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const panesById = useMemo( + () => new Map(collectAllPanes((layout ?? EMPTY_LAYOUT).root).map((pane) => [pane.id, pane])), + [layout], + ); + const handleDragStart = useCallback((event: DragStartEvent) => { + const data = asWorkspaceTabDragData(event.active.data.current); + if (!data) { + setActiveDragTabId(null); + setDropPreview(null); + setTabDropPreview(null); + return; + } + setActiveDragTabId(data.tabId); + }, []); + + const handleDragCancel = useCallback(() => { + setActiveDragTabId(null); + setDropPreview(null); + setTabDropPreview(null); + }, []); + + const updateDropPreview = useCallback( + (event: Pick | Pick) => { + const activeData = asWorkspaceTabDragData(event.active.data.current); + const overData = asDragOverData(event.over?.data.current); + + if ( + !activeData || + !overData || + !acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData) + ) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + + const rects = resolveDragMoveRects(event); + if (!rects) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + + const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null; + const destinationPaneId = + overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop" + ? overData.paneId + : null; + const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main"; + if ( + !activeTab || + !panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost) + ) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + + if (overData?.kind === "workspace-tab") { + const preview = computeTabOverDropPreview({ + activeData, + overData, + rects, + panesById, + uiTabs, + }); + setDropPreview(null); + setTabDropPreview(preview); + return; + } + + setTabDropPreview(null); + if (overData?.kind !== "split-pane-drop") { + setDropPreview(null); + return; + } + + setDropPreview(computePaneOverDropPreview({ overData, rects })); + }, + [allowCrossPane, normalizedServerId, panesById, explorerSidebarPaneId, uiTabs], + ); + + const applyTabDropEnd = useCallback( + (input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => { + const { activeData, overData } = input; + const sourcePane = panesById.get(activeData.paneId) ?? null; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!sourcePane || !targetPane) { + return; + } + + const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); + const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); + const resolvedTabDropPreview = + tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null; + if (sourceIndex < 0 || !resolvedTabDropPreview) { + return; + } + + if (activeData.paneId === overData.paneId) { + if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { + const nextTabs = arrayMove( + sourceTabs, + sourceIndex, + resolvedTabDropPreview.insertionIndex, + ); + onReorderTabsInPane( + activeData.paneId, + nextTabs.map((tab) => tab.tabId), + ); + } + return; + } + + const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); + nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId); + onMoveTabToPane(activeData.tabId, overData.paneId); + onReorderTabsInPane(overData.paneId, nextTargetTabIds); + }, + [onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs], + ); + + const applyPaneDropEnd = useCallback( + (input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => { + const { activeData, overData } = input; + if (dropPreview?.paneId !== overData.paneId) { + return; + } + if (dropPreview.position === "center") { + if (activeData.paneId !== overData.paneId) { + onMoveTabToPane(activeData.tabId, overData.paneId); + } + return; + } + onSplitPane({ + tabId: activeData.tabId, + targetPaneId: overData.paneId, + position: dropPreview.position, + }); + }, + [dropPreview, onMoveTabToPane, onSplitPane], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const activeData = asWorkspaceTabDragData(event.active.data.current); + const overData = asDragOverData(event.over?.data.current); + + setActiveDragTabId(null); + + if ( + activeData && + overData && + event.over && + acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData) + ) { + if (overData?.kind === "workspace-tab") { + applyTabDropEnd({ activeData, overData }); + } else if (overData?.kind === "split-pane-drop") { + applyPaneDropEnd({ activeData, overData }); + } + } + + setDropPreview(null); + setTabDropPreview(null); + }, + [allowCrossPane, applyTabDropEnd, applyPaneDropEnd], + ); + + const context = useMemo( + () => ({ activeDragTabId, dropPreview, tabDropPreview, externalDndContext: true }), + [activeDragTabId, dropPreview, tabDropPreview], + ); + return ( + + {children} + + {activeDragTabId ? ( + + ) : null} + + + ); +} +function DragOverlayTabChip({ + tabId, + uiTabs, + normalizedServerId, + normalizedWorkspaceId, +}: { + tabId: string; + uiTabs: WorkspaceTab[]; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const tab = uiTabs.find((t) => t.tabId === tabId); + const descriptor = useMemo( + () => + tab + ? { + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + } + : null, + [tab], + ); + if (!descriptor) { + return null; + } + return ( + + ); +} + +function DragOverlayTabChipInner({ + tab, + normalizedServerId, + normalizedWorkspaceId, +}: { + tab: WorkspaceTabDescriptor; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const { t } = useTranslation(); + return ( + + {(presentation) => { + const label = + presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; + + return ( + + + + {label} + + + ); + }} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + dragOverlayChip: { + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.borderAccent, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + maxWidth: 200, + }, + dragOverlayLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + flexShrink: 1, + }, +})); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 4ae1764a02..f810ef19c6 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,4 +1,5 @@ import type { SelectedFile } from "@/attachments/selected-file"; +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { @@ -54,7 +55,7 @@ export interface ComposerSendClient { images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, - ) => Promise; + ) => Promise; uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{ requestId: string; file: { @@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: { for (const file of input.files) { const bytes = await file.readBytes(); - if (bytes.byteLength > 50 * 1024 * 1024) { + if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) { throw new Error( i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }), ); diff --git a/packages/app/src/composer/diff-stat-pill.test.tsx b/packages/app/src/composer/diff-stat-pill.test.tsx index 96e1e1139a..5115666994 100644 --- a/packages/app/src/composer/diff-stat-pill.test.tsx +++ b/packages/app/src/composer/diff-stat-pill.test.tsx @@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill"; const WORKSPACE_ID = "workspace"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: WORKSPACE_ID, projectId: "project", projectDisplayName: "Project", diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 4b6407c872..e3720ccbee 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -179,8 +179,8 @@ export function ComposerDock({ {composer} - {overlay} + {overlay} ); } @@ -214,8 +214,8 @@ export function ComposerDock({ {composer} - {overlay} + {overlay} ); } diff --git a/packages/app/src/composer/dock/overlay-placement.test.ts b/packages/app/src/composer/dock/overlay-placement.test.ts new file mode 100644 index 0000000000..da4262af12 --- /dev/null +++ b/packages/app/src/composer/dock/overlay-placement.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { expect, it } from "vitest"; + +it("keeps dock overlays outside keyboard translation in every native layout", () => { + const source = ts.createSourceFile( + "dock.tsx", + readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let surfaces = 0; + function visit(node: ts.Node) { + if ( + ts.isJsxElement(node) && + node.openingElement.tagName.getText(source) === "KeyboardTranslateView" + ) { + surfaces++; + function assertStationary(child: ts.Node) { + if (ts.isJsxExpression(child)) + expect(child.expression?.getText(source)).not.toBe("overlay"); + ts.forEachChild(child, assertStationary); + } + ts.forEachChild(node, assertStationary); + } + ts.forEachChild(node, visit); + } + visit(source); + expect(surfaces).toBe(2); +}); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 8bc31bb176..82de9d3d73 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -2,7 +2,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -126,17 +126,13 @@ afterEach(async () => { }); }); -let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; -beforeAll(async () => { - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - ({ useAgentInputDraft } = await import("./input-draft")); +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, }); +const { useAgentInputDraft } = await import("./input-draft"); describe("useAgentInputDraft live contract", () => { beforeEach(() => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c14db9a119..2eb606a7f7 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UserComposerAttachment } from "@/attachments/types"; import type { TextReplacement } from "@/composer/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { useAgentFormState, type CreateAgentInitialValues, diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 30411aeb00..26bf548064 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -24,7 +24,7 @@ export function validateDraftSubmission(input: { }; autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null; workspaceDirectory: string | null; - hasClient: boolean; + isConnected: boolean; }): string | null { const { text, @@ -32,7 +32,7 @@ export function validateDraftSubmission(input: { composerState, autoSubmitConfig, workspaceDirectory, - hasClient, + isConnected, } = input; const readiness = resolveSubmissionReadiness({ text, @@ -46,7 +46,7 @@ export function validateDraftSubmission(input: { }, autoSubmitConfig, workspaceDirectory, - hasClient, + isConnected, }); return readiness.ok ? null : (readiness.reason ?? null); } diff --git a/packages/app/src/composer/draft/workspace-tab.test.ts b/packages/app/src/composer/draft/workspace-tab.test.ts index 5952083a62..1d7e2d4b2c 100644 --- a/packages/app/src/composer/draft/workspace-tab.test.ts +++ b/packages/app/src/composer/draft/workspace-tab.test.ts @@ -17,12 +17,26 @@ function validate(overrides = {}) { composerState: baseComposerState, autoSubmitConfig: null, workspaceDirectory: "/tmp/project", - hasClient: true, + isConnected: true, ...overrides, }); } describe("workspace draft agent model validation", () => { + test("reports an offline host before an absent catalog or pending model defaults", () => { + expect( + validate({ + isConnected: false, + composerState: { + ...baseComposerState, + providerDefinitions: [], + selectedProvider: null, + isModelLoading: true, + }, + }), + ).toBe("Host is not connected"); + }); + test("allows a ready provider with no models to submit without a selected model", () => { expect(validate({})).toBeNull(); }); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index b54b344a36..c4ef45cb3f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -41,7 +41,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; -import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceTabPersistenceKey, @@ -305,6 +305,7 @@ export function WorkspaceDraftAgentTab({ }: WorkspaceDraftAgentTabProps) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ workspaceDirectory: w.workspaceDirectory, id: w.id, @@ -389,7 +390,7 @@ export function WorkspaceDraftAgentTab({ const editingSession = useComposerEditingSession("chat"); const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( - COMPACT_FORM_FACTOR_WIDTH, + COMPACT_CONTENT_WIDTH, { initialIsBelow: isCompactFormFactor }, ); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ @@ -414,11 +415,10 @@ export function WorkspaceDraftAgentTab({ openWorkspaceChanges({ isCompact: isCompactFormFactor, workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }), - checkout: { serverId, cwd: composerState.workingDir, isGit: true }, preferences: openInSidePane, }); }, - [composerState.workingDir, isCompactFormFactor, openInSidePane, serverId, workspaceId], + [isCompactFormFactor, openInSidePane, serverId, workspaceId], ); const { @@ -445,7 +445,7 @@ export function WorkspaceDraftAgentTab({ composerState, autoSubmitConfig, workspaceDirectory: draftWorkingDirectory, - hasClient: Boolean(client), + isConnected, }); }, onBeforeSubmit: async () => { diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts index 31c3765426..7dfcb0d438 100644 --- a/packages/app/src/composer/editing-session/index.test.ts +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -2,6 +2,31 @@ import { describe, expect, it } from "vitest"; import { ComposerEditingSession, ComposerEditingSessions } from "./index"; describe("composer editing session", () => { + it("appends commands to the current editor buffer and focuses it", () => { + const session = new ComposerEditingSession(); + let text = "draft typed since publication"; + let focuses = 0; + let selection = { start: 0, end: 0 }; + const detach = session.attachEditor({ + getText: () => text, + replaceText: (next, nextSelection) => { + text = next; + selection = nextSelection; + }, + focus: () => { + focuses += 1; + }, + }); + session.insertCommand("/review"); + expect(selection).toEqual({ start: text.length, end: text.length }); + session.insertCommand("/test"); + expect(text).toBe("draft typed since publication /review /test "); + expect(focuses).toBe(2); + expect(selection).toEqual({ start: text.length, end: text.length }); + detach(); + session.insertCommand("/ignored"); + expect(text).toBe("draft typed since publication /review /test "); + }); it("publishes fullscreen changes once per transition", () => { const session = new ComposerEditingSession(); const changes: boolean[] = []; diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 6ad84d632d..237b15251a 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -30,10 +30,29 @@ export interface ComposerRetainedEditor { */ export class ComposerEditingSession { private fullscreen = false; + private editor: { + getText(): string; + replaceText(text: string, selection: ComposerSelection): void; + focus(): void; + } | null = null; private retained: ComposerRetainedEditor | null = null; private listeners = new Set<() => void>(); constructor(private readonly onChange?: () => void) {} getFullscreen = () => this.fullscreen; + attachEditor(editor: NonNullable) { + this.editor = editor; + return () => { + if (this.editor === editor) this.editor = null; + }; + } + insertCommand(command: string) { + const editor = this.editor; + if (!editor) return; + const text = editor.getText(); + const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `; + editor.replaceText(nextText, { start: nextText.length, end: nextText.length }); + editor.focus(); + } /** Called by the unmounting editor; the next mount of this session takes it. */ retainEditor(retained: ComposerRetainedEditor) { this.retained = retained; @@ -119,6 +138,20 @@ const SessionsContext = createContext(null); const WorkspaceContext = createContext(null); const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); +/** Keep the workspace's editing identity when a sheet renders in the app overlay host. */ +export function useComposerEditingEnvironmentBridge() { + const sessions = useContext(SessionsContext); + const workspaceKey = useContext(WorkspaceContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [sessions, workspaceKey], + ); +} + /** App-container ownership survives replacement of both the gesture shell and tab layout. */ export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { const [sessions] = useState(() => new ComposerEditingSessions()); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 10d670fcae..d6b4bae63f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -55,7 +55,11 @@ import { } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentSentMessages, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -67,7 +71,7 @@ import { } from "./input/input"; import type { ImageAttachment, MessagePayload, TextReplacement } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { @@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { ComposerRecall } from "@/composer/recall"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; @@ -315,6 +320,7 @@ interface PendingFileAttachment { } interface RenderAttachmentTrayArgs { + serverId: string; selectedAttachments: ComposerAttachment[]; pendingFiles: PendingFileAttachment[]; isComposerLocked: boolean; @@ -331,6 +337,7 @@ interface RenderAttachmentTrayArgs { function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { + serverId, selectedAttachments, pendingFiles, isComposerLocked, @@ -343,6 +350,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu {selectedAttachments.map((attachment, index) => renderComposerAttachmentPill({ + serverId, attachment, index, disabled: isComposerLocked, @@ -393,6 +401,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { } interface RenderComposerAttachmentPillArgs { + serverId: string; attachment: ComposerAttachment; index: number; disabled: boolean; @@ -453,6 +462,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R if (attachment.kind === "plugin_resource") { return ( ; + recall: ComposerRecall; configuration: Omit< Parameters[0], "userInput" | "cursorIndex" | "onAutocompleteApplied" >; inputRef: React.RefObject; - anchorRef: React.RefObject; + children: React.ReactNode; + anchorStyle: React.ComponentProps["style"]; show: boolean; ref: React.Ref; }) { @@ -1015,9 +1029,11 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); + // A recalled message is sent as is; its leading `/command` must not open the picker. + const isShown = show && !recall.isShowing(userInput); const handleKeyPress = autocomplete.onKeyPress; - useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ - show, + useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [ + isShown, handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; @@ -1027,8 +1043,8 @@ function ComposerAutocompleteBinding({ ); return ( + > + {children} + ); } @@ -1357,6 +1375,7 @@ function ComposerContentImpl({ ); useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); const autocompleteRef = useRef(null); + const recall = useMemo(() => new ComposerRecall(), []); const [isProcessing, setIsProcessing] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const nextPendingFileId = useRef(0); @@ -1976,10 +1995,19 @@ function ComposerContentImpl({ const hasSendableContent = hasText || selectedAttachments.length > 0; - // Handle keyboard navigation for command autocomplete. + // An open autocomplete owns the arrows; otherwise they may recall sent messages. const handleCommandKeyPress = useCallback( - (event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false, - [], + (event: ComposerKeyPressEvent) => { + if (autocompleteRef.current?.onKeyPress(event)) return true; + const recalledText = recall.handleKeyPress(event, () => + selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId), + ); + if (recalledText === null) return false; + event.preventDefault(); + replaceUserInput(recalledText); + return true; + }, + [agentId, recall, replaceUserInput, serverId], ); const cancelButtonStyle = useMemo( @@ -2295,6 +2323,7 @@ function ComposerContentImpl({ const attachmentTray = useMemo( () => renderAttachmentTray({ + serverId, selectedAttachments, pendingFiles, isComposerLocked, @@ -2314,6 +2343,7 @@ function ComposerContentImpl({ handleOpenAttachment, handleRemoveAttachment, isComposerLocked, + serverId, selectedAttachments, pendingFiles, t, @@ -2353,7 +2383,6 @@ function ComposerContentImpl({ pluginClientSlashCommands, ], ); - const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; @@ -2412,16 +2441,16 @@ function ComposerContentImpl({ {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - - + {pluginAttachments.picker} - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 9fdab3fa28..8cb8cd4c32 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, useComposerFullscreen, @@ -27,6 +28,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; +import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { DictationOverlay } from "@/components/dictation-controls"; @@ -57,7 +59,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; @@ -76,6 +78,7 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({ })); import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -83,8 +86,10 @@ import { import { applyDictationTranscript, computeCanStartDictation, + resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveMessageInputEnterAction, resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, @@ -93,6 +98,11 @@ import { } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]]; + +function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] { + return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS; +} const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { @@ -110,6 +120,8 @@ export interface ComposerInputSnapshot { export interface ComposerKeyPressEvent { key: string; + /** Shift, Alt, Ctrl, or Meta was held. */ + hasModifier: boolean; preventDefault: () => void; input: ComposerInputSnapshot; } @@ -162,6 +174,7 @@ export interface MessageInputProps { * running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default * lives only in DEFAULT_CLIENT_SETTINGS. */ defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -210,6 +223,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; + altKey?: boolean; // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). isComposing?: boolean; keyCode?: number; @@ -396,6 +410,7 @@ interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; + sendShortcut: SendShortcut; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; @@ -411,30 +426,37 @@ function handleDesktopKeyPressImpl( ): void { if (isImeComposingKeyboardEvent(event.nativeEvent)) return; + const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent; + if (ctx.onKeyPressCallback) { const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, + hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey), preventDefault: () => event.preventDefault(), input: ctx.input, }); if (handled) return; } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - if (event.nativeEvent.key !== "Enter") return; - if (!ctx.submitOnEnter) return; - if (shiftKey) return; - if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { - if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; - event.preventDefault(); - ctx.handleAlternateSendAction(); - return; - } + const action = resolveMessageInputEnterAction({ + submitOnEnter: ctx.submitOnEnter, + sendShortcut: ctx.sendShortcut, + shiftKey: shiftKey ?? false, + metaKey: metaKey ?? false, + ctrlKey: ctrlKey ?? false, + isAgentRunning: ctx.isAgentRunning, + onQueue: ctx.onQueue, + }); + if (action === null) return; if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); + if (action === "alternate-send") { + ctx.handleAlternateSendAction(); + return; + } ctx.handleDefaultSendAction(); } @@ -765,8 +787,10 @@ function SendButtonTooltip({ canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, + onLongPressSend, isSendButtonDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, @@ -780,8 +804,10 @@ function SendButtonTooltip({ canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; + onLongPressSend: () => void; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; + submitAccessibilityHint: string | undefined; sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; @@ -796,8 +822,10 @@ function SendButtonTooltip({ void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; @@ -1151,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior, + sendShortcut: props.sendShortcut, onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, @@ -1228,6 +1258,7 @@ export const MessageInput = forwardRef( voiceAgentId, isAgentRunning, defaultSendBehavior, + sendShortcut, onQueue, onSubmitLoadingPress, onKeyPressCallback, @@ -1295,7 +1326,12 @@ export const MessageInput = forwardRef( // Read pointer capability when restoring, without making a pointer change itself // a focus trigger. useLayoutEffect(() => { - if (isPaneFocused && focusIntentRef.current && hasFinePointer()) { + const finePointer = hasFinePointer(); + if (!isPaneFocused && isWeb && !finePointer) { + // Hiding a retained tab only blurs its editor after the browser renders. + // A quick return before that frame would otherwise keep touch focus. + textInputRef.current?.blur(); + } else if (isPaneFocused && focusIntentRef.current && finePointer) { textInputRef.current?.focus(); } }, [isPaneFocused]); @@ -1371,6 +1407,16 @@ export const MessageInput = forwardRef( [onChangeText, updateComposerHeightForText, updateLiveTextPresence], ); + useLayoutEffect( + () => + editingSession.attachEditor({ + getText: () => textInputRef.current?.getText() ?? valueRef.current, + replaceText, + focus: () => textInputRef.current?.focus(), + }), + [editingSession, replaceText], + ); + useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); @@ -1696,6 +1742,19 @@ export const MessageInput = forwardRef( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); + const alternateSendAction = resolveAlternateSendAction({ + defaultSendBehavior, + isAgentRunning, + onQueue, + }); + + const handleLongPressSend = useCallback(() => { + if (alternateSendAction && isNative) { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + } + handleAlternateSendAction(); + }, [alternateSendAction, handleAlternateSendAction]); + usePasteImagesEffect({ textarea: webTextArea, isConnected, @@ -1729,6 +1788,7 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, + sendShortcut, isAgentRunning, onQueue, isSubmitDisabled, @@ -1742,6 +1802,7 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, + sendShortcut, isAgentRunning, onQueue, isSubmitDisabled, @@ -1783,6 +1844,11 @@ export const MessageInput = forwardRef( isAgentRunning, t, }); + const submitAccessibilityHint = resolveSubmitAccessibilityHint({ + canPressLoadingButton, + alternateSendAction, + t, + }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, @@ -1943,15 +2009,17 @@ export const MessageInput = forwardRef( canPressLoadingButton={canPressLoadingButton} onSubmitLoadingPress={onSubmitLoadingPress} onDefaultSendAction={handleDefaultSendAction} + onLongPressSend={handleLongPressSend} isSendButtonDisabled={isPresentedSendDisabled} submitAccessibilityLabel={submitAccessibilityLabel} + submitAccessibilityHint={submitAccessibilityHint} sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} - sendKeys={DEFAULT_SEND_KEYS} + sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), @@ -1962,14 +2030,17 @@ export const MessageInput = forwardRef( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, + handleLongPressSend, isPresentedSendDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, submitLabel, submitButtonTestID, buttonIconSize, + sendShortcut, sendTooltipLabel, ], ); diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts index 15aa16b8e0..90c60d2781 100644 --- a/packages/app/src/composer/input/labels.test.ts +++ b/packages/app/src/composer/input/labels.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -12,6 +13,8 @@ const translations: Record = { "composer.input.sendAndInterrupt": "Send and interrupt", "composer.input.sendAndSteer": "Send and steer", "composer.input.sendMessage": "Send message", + "composer.input.longPressToQueue": "Long press to queue", + "composer.input.longPressToSendNow": "Long press to send now", "composer.input.queue": "Queue", "composer.input.send": "Send", "composer.voice.unmuteVoiceMode": "Unmute Voice mode", @@ -26,6 +29,37 @@ const translations: Record = { const t = ((key: string) => translations[key] ?? key) as never; describe("composer input labels", () => { + it("names the long-press action in the submit hint only when one exists", () => { + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "queue", + t, + }), + ).toBe("Long press to queue"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "send", + t, + }), + ).toBe("Long press to send now"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: null, + t, + }), + ).toBeUndefined(); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: true, + alternateSendAction: "queue", + t, + }), + ).toBeUndefined(); + }); + it("resolves submit accessibility labels from translations", () => { expect( resolveSubmitAccessibilityLabel({ diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts index 0820cd7ecb..50ac3eb8bf 100644 --- a/packages/app/src/composer/input/labels.ts +++ b/packages/app/src/composer/input/labels.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import type { SendBehavior } from "@/hooks/use-settings/storage"; +import type { AlternateSendAction } from "./state"; export function resolveSubmitAccessibilityLabel(input: { submitButtonAccessibilityLabel: string | undefined; @@ -22,6 +23,19 @@ export function resolveSubmitAccessibilityLabel(input: { return input.t("composer.input.sendMessage"); } +export function resolveSubmitAccessibilityHint(input: { + canPressLoadingButton: boolean; + alternateSendAction: AlternateSendAction | null; + t: TFunction; +}): string | undefined { + if (input.canPressLoadingButton || !input.alternateSendAction) return undefined; + return input.t( + input.alternateSendAction === "queue" + ? "composer.input.longPressToQueue" + : "composer.input.longPressToSendNow", + ); +} + export function resolveVoiceAccessibilityLabel(input: { isRealtimeVoiceForCurrentAgent: boolean; isMuted: boolean; diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 6f2cdef74d..ad492976b3 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { applyDictationTranscript, computeCanStartDictation, + type MessageInputEnterContext, resolveActiveSendBehavior, resolveComposerSurfacePresentation, resolveComposerSurfaceText, resolveGlyphRailOffset, + resolveMessageInputEnterAction, resolveRetainedSelection, + resolveAlternateSendAction, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -276,6 +279,253 @@ describe("composer send behavior", () => { expect(defaultAction.calls).toEqual(["queue"]); expect(alternateAction.calls).toEqual(["send"]); }); + + it("sends with the alternate action while no turn is running", () => { + for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) { + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior, + isAgentRunning: false, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + expect(alternateAction.calls).toEqual(["send"]); + } + }); + + it("offers an alternate action only while a turn runs and the composer can queue", () => { + const onQueue = () => undefined; + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "interrupt", + isAgentRunning: true, + onQueue, + }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }), + ).toBe("send"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }), + ).toBeNull(); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: undefined, + }), + ).toBeNull(); + }); +}); + +describe("resolveMessageInputEnterAction", () => { + function enterContext( + overrides: Partial = {}, + ): MessageInputEnterContext { + return { + submitOnEnter: true, + sendShortcut: "enter", + shiftKey: false, + metaKey: false, + ctrlKey: false, + isAgentRunning: false, + onQueue: () => undefined, + ...overrides, + }; + } + + it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => { + expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })), + ).toBeNull(); + }); + + it("still returns null when modifier Enter sends and submit on enter is disabled", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }), + ), + ).toBeNull(); + }); + + it("sends on plain Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send"); + }); + + it("returns null for Shift+Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => { + expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send"); + expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send"); + }); + + it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => { + expect( + resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + expect( + resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + }); + + it("returns null for plain and Shift+Enter when modifier Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })), + ).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })), + ).toBe("default-send"); + }); + + it("uses the default action on modifier Enter while running when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + }); + + describe("composed with the send actions", () => { + function actions() { + const calls: string[] = []; + return { + calls, + handleSendMessage: () => calls.push("send"), + handleQueueMessage: () => calls.push("queue"), + onQueue: () => undefined, + }; + } + + function resolveCommandEnterToSendModifierEnter() { + return resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ); + } + + it("queues through the default send action when queue behavior is selected and the agent is running", () => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["queue"]); + }); + + it("sends through the default send action when queue behavior is selected and the agent is idle", () => { + const sendAction = actions(); + + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }), + ), + ).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: false, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }); + + it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)( + "sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running", + ({ defaultSendBehavior }) => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior, + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }, + ); + }); }); describe("stopRealtimeVoice", () => { diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 698965b5ac..67c8030745 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { MessagePayload, TextReplacement } from "@/composer/types"; @@ -125,14 +126,53 @@ export function runDefaultSendAction(ctx: SendActionContext): void { ctx.handleSendMessage(); } +export type AlternateSendAction = "queue" | "send"; + +/** + * What Mod+Enter and a long-press on Send do instead of the default send. Null while + * no turn is running, where both fall back to the default send. + */ +export function resolveAlternateSendAction( + ctx: Pick, +): AlternateSendAction | null { + if (!ctx.isAgentRunning || !ctx.onQueue) return null; + return ctx.defaultSendBehavior === "queue" ? "send" : "queue"; +} + export function runAlternateSendAction(ctx: SendActionContext): void { - if (ctx.defaultSendBehavior === "queue") { + const action = resolveAlternateSendAction(ctx); + if (action === "queue") { + ctx.handleQueueMessage(); + return; + } + if (action === "send") { ctx.handleSendMessage(); return; } - if (ctx.isAgentRunning && ctx.onQueue) { - ctx.handleQueueMessage(); - } + runDefaultSendAction(ctx); +} + +export type MessageInputEnterAction = "alternate-send" | "default-send"; + +export interface MessageInputEnterContext { + submitOnEnter: boolean; + sendShortcut: SendShortcut; + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; +} + +export function resolveMessageInputEnterAction( + ctx: MessageInputEnterContext, +): MessageInputEnterAction | null { + if (!ctx.submitOnEnter) return null; + if (ctx.shiftKey) return null; + const hasModifier = ctx.metaKey || ctx.ctrlKey; + if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null; + if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send"; + return "default-send"; } export function runMessageInputKeyboardAction( diff --git a/packages/app/src/composer/recall.test.ts b/packages/app/src/composer/recall.test.ts new file mode 100644 index 0000000000..a1c6b618fc --- /dev/null +++ b/packages/app/src/composer/recall.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { ComposerKeyPressEvent } from "@/composer/input/input"; +import { ComposerRecall } from "@/composer/recall"; + +const SENT = ["add tests", "fix the auth bug"]; + +function press( + key: string, + text: string, + options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {}, +): ComposerKeyPressEvent { + const caret = options.caret ?? text.length; + return { + key, + hasModifier: options.hasModifier ?? false, + preventDefault: () => undefined, + input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } }, + }; +} + +/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */ +function composerWithRecall(sent: readonly string[] = SENT) { + const recall = new ComposerRecall(); + let text = ""; + return { + recall, + get text() { + return text; + }, + type(next: string) { + text = next; + }, + press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) { + const result = recall.handleKeyPress(press(key, text, options), () => sent); + if (result !== null) text = result; + return result; + }, + }; +} + +describe("ComposerRecall", () => { + it("walks back through sent messages and forward to an empty composer", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp")).toBe("add tests"); + expect(composer.press("ArrowUp")).toBe("fix the auth bug"); + expect(composer.press("ArrowDown")).toBe("add tests"); + expect(composer.press("ArrowDown")).toBe(""); + expect(composer.recall.isShowing(composer.text)).toBe(false); + }); + + it("leaves the caret to the input past the oldest message", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.text).toBe("fix the auth bug"); + }); + + it("only starts from an empty composer", () => { + const composer = composerWithRecall(); + composer.type("half written"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("ignores Down from an empty composer and Up with no history", () => { + expect(composerWithRecall().press("ArrowDown")).toBeNull(); + expect(composerWithRecall([]).press("ArrowUp")).toBeNull(); + }); + + it("hands the arrows back to the caret once the recalled message is edited", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.type("add tests for login"); + + expect(composer.recall.isShowing(composer.text)).toBe(false); + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("moves between lines of a multi-line message before changing message", () => { + const composer = composerWithRecall(["first line\nsecond line", "older"]); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull(); + expect(composer.press("ArrowUp", { caret: 3 })).toBe("older"); + expect(composer.press("ArrowDown")).toBe("first line\nsecond line"); + expect(composer.press("ArrowDown", { caret: 3 })).toBeNull(); + expect(composer.press("ArrowDown")).toBe(""); + }); + + it("ignores modified arrows, other keys, and a selection", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull(); + expect(composer.press("Enter")).toBeNull(); + composer.press("ArrowUp"); + const recall = composer.recall; + expect( + recall.handleKeyPress( + press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }), + () => SENT, + ), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/recall.ts b/packages/app/src/composer/recall.ts new file mode 100644 index 0000000000..24984d39e1 --- /dev/null +++ b/packages/app/src/composer/recall.ts @@ -0,0 +1,68 @@ +import type { ComposerKeyPressEvent } from "@/composer/input/input"; + +interface RecalledMessage { + index: number; + text: string; +} + +/** + * Walks the messages already sent to an agent from an empty composer, like a + * shell's Up/Down history. A message stays recalled only while the composer + * still holds its exact text; the first edit hands the arrows back to the caret. + */ +export class ComposerRecall { + private recalled: RecalledMessage | null = null; + + /** True while the composer shows a recalled message the user has not edited. */ + isShowing(text: string): boolean { + return this.recalled !== null && this.recalled.text === text; + } + + /** + * Returns the text the composer should show for this key press, or null to + * leave the key to the input. `readSentMessages` is newest first and is only + * read for keys recall acts on. + */ + handleKeyPress( + event: ComposerKeyPressEvent, + readSentMessages: () => readonly string[], + ): string | null { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null; + if (event.hasModifier) return null; + const { text, selection } = event.input; + if (selection.start !== selection.end) return null; + + const recalled = this.isShowing(text) ? this.recalled : null; + if (!recalled) { + this.recalled = null; + if (event.key !== "ArrowUp" || text.length > 0) return null; + return this.show(readSentMessages(), 0); + } + + if (event.key === "ArrowUp") { + if (!isCaretOnFirstLine(text, selection.start)) return null; + return this.show(readSentMessages(), recalled.index + 1); + } + if (!isCaretOnLastLine(text, selection.start)) return null; + if (recalled.index === 0) { + this.recalled = null; + return ""; + } + return this.show(readSentMessages(), recalled.index - 1); + } + + private show(messages: readonly string[], index: number): string | null { + const text = messages[index]; + if (text === undefined) return null; + this.recalled = { index, text }; + return text; + } +} + +function isCaretOnFirstLine(text: string, caret: number): boolean { + return !text.slice(0, caret).includes("\n"); +} + +function isCaretOnLastLine(text: string, caret: number): boolean { + return !text.slice(caret).includes("\n"); +} diff --git a/packages/app/src/composer/submission/model.test.ts b/packages/app/src/composer/submission/model.test.ts index 147ccce954..3aea318f4f 100644 --- a/packages/app/src/composer/submission/model.test.ts +++ b/packages/app/src/composer/submission/model.test.ts @@ -4,6 +4,7 @@ import { beginMessageSubmission, getActiveMessageSubmissions, getSendingClientMessageIds, + hasActiveMessageSubmission, observeMessageSubmissionCanonical, rejectMessageSubmission, } from "./model"; @@ -53,6 +54,21 @@ describe("message submission transactions", () => { expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]); }); + it("reports activity until every submission is provider acknowledged", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1" }), + { clientMessageId: "client-2" }, + ); + const oneAcknowledged = observeMessageSubmissionCanonical(both, ["client-1"]); + + expect(hasActiveMessageSubmission(undefined)).toBe(false); + expect(hasActiveMessageSubmission(both)).toBe(true); + expect(hasActiveMessageSubmission(oneAcknowledged)).toBe(true); + expect( + hasActiveMessageSubmission(observeMessageSubmissionCanonical(oneAcknowledged, ["client-2"])), + ).toBe(false); + }); + it("records provider acknowledgement without settling another transaction", () => { const both = beginMessageSubmission( beginMessageSubmission([], { clientMessageId: "client-1" }), diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 9ebc92ed20..b02a1e5e28 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -162,12 +162,34 @@ describe("composer track panel", () => { ); openPanel(); + expect(document.querySelector("button button")).toBeNull(); + expect(row("row")?.getAttribute("role")).toBe("menuitem"); click(row("row-archive") as Element); expect(onAction).toHaveBeenCalledTimes(1); expect(onPress).not.toHaveBeenCalled(); expect(isPanelOpen()).toBe(true); }); + + it.each(["Enter", " "])("selects a track row with %j", (key) => { + const select = vi.fn(); + mount( + + + Subagent one + + , + ); + openPanel(); + const target = row("row") as HTMLElement; + act(() => { + target.focus(); + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); + }); + expect(select).toHaveBeenCalledTimes(1); + expect(isPanelOpen()).toBe(false); + }); }); describe("composer track pill status mark", () => { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index e33cd6ba53..341e7dc8e0 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -2,11 +2,14 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } fro import { Pressable, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { + MENU_ITEM_HEIGHT, MenuRoot, MenuSeparator, MenuSurface, MenuTrigger, useMenuContext, + useMenuDepth, + type MenuPageDefinition, type MenuTriggerState, } from "@/components/ui/menu"; import { StatusRing } from "@/components/status-ring"; @@ -60,6 +63,8 @@ export interface ComposerTrackPillProps { accessibilityLabel?: string; /** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */ children: ReactNode; + /** Pages a row can open in place of the list (`ComposerTrackRow` `opensPage`). */ + pages?: readonly MenuPageDefinition[]; } /** @@ -84,6 +89,7 @@ export function ComposerTrackPill({ testID, accessibilityLabel, children, + pages, }: ComposerTrackPillProps): ReactElement { return ( @@ -97,6 +103,7 @@ export function ComposerTrackPill({ align="start" offset={PANEL_OFFSET} sheetTitle={panelTitle} + pages={pages} minWidth={PANEL_MIN_WIDTH} maxWidth={PANEL_MAX_WIDTH} maxHeight={PANEL_MAX_HEIGHT} @@ -198,6 +205,11 @@ export interface ComposerTrackRowProps { * lands inside the panel is not. */ closeOnSelect?: boolean; + /** + * A page of the panel this row opens in place of the list, instead of running `onPress`. The + * panel stays open: Back returns to the list. + */ + opensPage?: string; disabled?: boolean; accessibilityLabel?: string; testID?: string; @@ -219,17 +231,27 @@ export function ComposerTrackRow({ children, onPress, closeOnSelect = true, + opensPage, disabled = false, accessibilityLabel, testID, }: ComposerTrackRowProps): ReactElement { - const { selectItem } = useMenuContext("ComposerTrackRow"); + const { selectItem, openSub } = useMenuContext("ComposerTrackRow"); + const depth = useMenuDepth(); const [hovered, setHovered] = useState(false); const handlePointerEnter = useCallback(() => setHovered(true), []); const handlePointerLeave = useCallback(() => setHovered(false), []); - const handleSelect = useCallback( - () => selectItem(onPress, closeOnSelect), - [closeOnSelect, onPress, selectItem], + const handleSelect = useCallback(() => { + if (opensPage) { + selectItem(() => openSub({ id: opensPage, depth }), false); + return; + } + selectItem(onPress, closeOnSelect); + }, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]); + + const itemDataSet = useMemo( + () => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }), + [disabled], ); const renderRow = useCallback( @@ -245,14 +267,15 @@ export function ComposerTrackRow({ [hovered, renderRow], ); - if (!onPress) { + if (!onPress && !opensPage) { return {renderRow(false)}; } return ( { backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined, }); + // The rail every panel row sits on: inset from the panel edge so the fill is a rounded block + // inside it, and tall enough that revealing an action button cannot resize the row. + // + // Compact and desktop differ here and only here. A finger needs the taller row a menu row gets + // on compact; a pointer does not, and the popover keeps its density. + const trackRow = { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 }, + marginHorizontal: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] }, + borderRadius: theme.borderRadius.md, + } as const; + return { bar: { position: "absolute", @@ -332,29 +375,8 @@ const styles = StyleSheet.create((theme) => { alignItems: "center", gap: theme.spacing[1], }, - // The rail every panel row sits on: inset from the panel edge so the fill is a rounded block - // inside it, and tall enough that revealing an action button cannot resize the row. - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 32, - marginHorizontal: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.md, - }, - rowActive: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 32, - marginHorizontal: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - }, + row: trackRow, + rowActive: { ...trackRow, backgroundColor: theme.colors.surface2 }, // Segments sit twice as far apart as a mark sits from its own text, so a count reads as // belonging to the mark on its left rather than to the words on its right. segments: { diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 9e9a15f6b4..739be45ee9 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -11,10 +11,11 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56; export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36; export const HEADER_TOP_PADDING_MOBILE = 8; -export const COMPACT_FORM_FACTOR_WIDTH = 500; +// Measured composer width; independent of the window's compact/wide breakpoint. +export const COMPACT_CONTENT_WIDTH = 500; -// Settings uses the canonical desktop list + detail layout. Its sidebar and -// detail target must fit together before it can share width with app navigation. +// Settings navigation can shrink below its preferred width to preserve the detail +// minimum. Both targets must fit before Settings shares width with app navigation. export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320; export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400; export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH = @@ -33,7 +34,7 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; -/** Compact layouts are the breakpoints below `md`. */ +/** Below md (600 points), sidebars overlay the workspace; wider layouts can dock them. */ export function isCompactBreakpoint(breakpoint: string | undefined): boolean { return breakpoint === "xs" || breakpoint === "sm"; } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 430e4ade74..89c32c0bd5 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -36,7 +36,6 @@ import { useSessionStore, type SessionState, } from "@/stores/session-store"; -import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; import { @@ -57,10 +56,7 @@ export type { DraftInput } from "@/stores/draft-store"; export type { Agent, ExplorerEntry, - ExplorerFile, ExplorerEntryKind, - ExplorerFileKind, - ExplorerEncoding, AgentFileExplorerState, } from "@/stores/session-store"; @@ -168,11 +164,6 @@ function resolveAgentAttentionNotification( }); } -type WorkspaceSetupProgressPayload = Extract< - SessionOutboundMessage, - { type: "workspace_setup_progress" } ->["payload"]; - function notifyVoiceAbortFailure( data: Extract["payload"], notifyError: (message: string) => void, @@ -225,7 +216,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); - const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // Track focused agent for heartbeat const focusedAgentId = useSessionStore( @@ -365,13 +355,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - const applyWorkspaceSetupProgress = useCallback( - (payload: WorkspaceSetupProgressPayload) => { - upsertWorkspaceSetupProgress({ serverId, payload }); - }, - [serverId, upsertWorkspaceSetupProgress], - ); - useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); const onStream = (message: SessionOutboundMessage) => { @@ -472,10 +455,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, - schedule: (task, delayMs) => { - const timeout = setTimeout(task, delayMs); - return () => clearTimeout(timeout); - }, }); viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); @@ -508,7 +487,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider "agent_permission_resolved", "agent.provider_subagents.update", "checkout_status_update", - "workspace_setup_progress", "status.server_info", ], { notifications: true }, @@ -547,11 +525,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); }); - const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => { - if (message.type !== "workspace_setup_progress") return; - applyWorkspaceSetupProgress(message.payload); - }); - const unsubStatus = onFeed("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -710,7 +683,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); - unsubWorkspaceSetupProgress(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -732,7 +704,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgents, setPendingPermissions, notifyAgentAttention, - applyWorkspaceSetupProgress, updateSessionServerInfo, toast, voiceRuntime, diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index c7ef8e281b..c0c3bea91f 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -8,6 +8,8 @@ import { } from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "/repo/worktree", projectId: "/repo", projectDisplayName: "Repo", diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 1bd32db53d..46b7d81a2d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -38,10 +38,10 @@ const CATALOG_DATA = [ title: "Auggie CLI", description: "Augment Code's powerful software agent, backed by industry-leading context engine", - version: "0.33.0", + version: "0.36.0", iconId: "auggie", installLink: "https://www.augmentcode.com/", - command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"], + command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"], env: { AUGMENT_DISABLE_AUTO_UPDATE: "1", }, @@ -60,10 +60,10 @@ const CATALOG_DATA = [ title: "Cline", description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", - version: "3.0.46", + version: "3.0.73", iconId: "cline", installLink: "https://cline.bot/cli", - command: ["npx", "-y", "cline@3.0.46", "--acp"], + command: ["npx", "-y", "cline@3.0.73", "--acp"], }, { id: "codebuddy-code", @@ -123,17 +123,17 @@ const CATALOG_DATA = [ id: "deepagents", title: "DeepAgents", description: "Batteries-included AI coding and general purpose agent powered by LangChain.", - version: "0.1.20", + version: "0.1.36", iconId: "deepagents", installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview", - command: ["npx", "-y", "deepagents-acp@0.1.20"], + command: ["npx", "-y", "deepagents-acp@0.1.36"], }, { id: "devin", title: "Devin CLI", description: "Cognition's Devin for Terminal via Agent Client Protocol", version: "manual", - iconId: null, + iconId: "devin", installLink: "https://cli.devin.ai/docs", command: ["devin", "acp"], }, @@ -141,29 +141,29 @@ const CATALOG_DATA = [ id: "dimcode", title: "DimCode", description: "A coding agent that puts leading models at your command.", - version: "0.2.36", + version: "0.5.21", iconId: "dimcode", installLink: "https://dimcode.dev/docs/acp.html", - command: ["npx", "-y", "dimcode@0.2.36", "acp"], + command: ["npx", "-y", "dimcode@0.5.21", "acp"], }, { id: "dirac", title: "Dirac", description: "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", - version: "0.4.22", + version: "0.5.23", iconId: "dirac", installLink: "https://dirac.run", - command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"], + command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"], }, { id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.179.0", + version: "0.249.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", @@ -174,19 +174,19 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.9.22", + version: "0.10.47", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"], }, { id: "gemini", title: "Gemini CLI", description: "Google's official CLI for Gemini", - version: "0.52.0", + version: "0.65.0", iconId: "gemini", installLink: "https://geminicli.com", - command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"], + command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"], }, { id: "gjc", @@ -205,11 +205,11 @@ const CATALOG_DATA = [ id: "glm-acp-agent", title: "GLM Agent", description: - "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", - version: "1.3.0", + "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", + version: "1.15.0", iconId: "glm-acp-agent", installLink: "https://github.com/stefandevo/glm-acp-agent", - command: ["npx", "-y", "glm-acp-agent@1.3.0"], + command: ["npx", "-y", "glm-acp-agent@1.15.0"], }, { id: "goose", @@ -235,7 +235,7 @@ const CATALOG_DATA = [ title: "Hermes", description: "Nous Research self-improving AI agent", version: "manual", - iconId: null, + iconId: "hermes", installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", command: ["hermes", "acp"], }, @@ -272,7 +272,7 @@ const CATALOG_DATA = [ title: "Kiro CLI", description: "Amazon's AI coding agent with native ACP support", version: "manual", - iconId: null, + iconId: "kiro", installLink: "https://kiro.dev/docs/cli/acp/", command: ["kiro-cli", "acp"], }, @@ -289,20 +289,23 @@ const CATALOG_DATA = [ id: "minimax-code", title: "MiniMax Code", description: "MiniMax's coding agent for the terminal", - version: "0.1.2", + version: "0.6.13", iconId: "minimax-code", installLink: "https://agent.minimax.io", - command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"], + // `-p ` names the package and `mcode` names its binary. Without the + // explicit binary, npx resolves the executable from the unscoped package + // name (`code`), which this package does not publish. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }, { id: "minion-code", title: "Minion Code", description: "An enhanced AI code assistant built on the Minion framework with rich development tools", - version: "0.1.44", + version: "0.1.45", iconId: "minion-code", installLink: "https://github.com/femto/minion-code", - command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"], + command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"], }, { id: "mistral-vibe", @@ -317,10 +320,10 @@ const CATALOG_DATA = [ id: "nova", title: "Nova", description: "Nova by Compass AI - a fully-fledged software engineer at your command", - version: "1.1.29", + version: "1.1.59", iconId: "nova", installLink: "https://www.compassap.ai/portfolio/nova.html", - command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"], + command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"], }, { id: "poolside", @@ -335,19 +338,19 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "1.1.4", + version: "1.1.78", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"], }, { id: "qwen-code", title: "Qwen Code", description: "Alibaba's Qwen coding assistant", - version: "0.20.1", + version: "0.26.0", iconId: "qwen-code", installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview", - command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"], + command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"], }, { id: "sigit", diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 68b389fe15..87c62f81ca 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -8,7 +8,7 @@ import { } from "./provider-snapshot-cache"; import { queryClient as singletonQueryClient } from "./query-client"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { agentCommandsQueryRoot } from "@/agent-commands/cache"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 3bf9e99f9b..6599c28467 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -51,7 +51,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} emit: (message: Extract) => void; subscribeCheckoutDiffCalls: Array<{ cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }; subscriptionId: string; }>; unsubscribeCheckoutDiffCalls: string[]; @@ -67,7 +67,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} }; const subscribeCheckoutDiffCalls: Array<{ cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }; subscriptionId: string; }> = []; const unsubscribeCheckoutDiffCalls: string[] = []; @@ -379,12 +379,12 @@ describe("server data push router", () => { }); }); - it("subscribes active checkout diff queries and writes matching diff events", () => { + it.each(["base", "all"] as const)("routes %s diff snapshots and updates", (mode) => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; const cwd = "/repo"; - const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const queryKey = checkoutDiffQueryKey(serverId, cwd, mode, "main", true); const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const observer = new QueryObserver(queryClient, { queryKey, @@ -397,17 +397,18 @@ describe("server data push router", () => { serverId, subscriptionId, cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, + compare: { mode, baseRef: "main", ignoreWhitespace: true }, }), }); const unsubscribeObserver = observer.subscribe(() => undefined); const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId }); + expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1); const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId; expect(fake.subscribeCheckoutDiffCalls).toEqual([ { cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, + compare: { mode, baseRef: "main", ignoreWhitespace: true }, subscriptionId: serverSubscriptionId, }, ]); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 5ab3069bd0..cc32f8f8e3 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,11 +1,12 @@ import type { OwnedSubscription } from "@getpaseo/client"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, MutableDaemonConfig, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { invalidateCommandsForProviderChange } from "@/agent-commands/cache"; import { shareCheckoutDiff } from "@/git/diff-sharing"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; @@ -33,7 +34,7 @@ type CheckoutDiffCachePayload = Omit(queryKey); + const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey); let announcement: typeof snapshot | undefined = snapshot; void input.queryClient.cancelQueries({ queryKey, exact: true }); - await input.queryClient.fetchQuery({ + const current = await input.queryClient.fetchQuery({ queryKey, staleTime: 0, structuralSharing: false, @@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: { }); }, }); - if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - } + void invalidateCommandsForProviderChange({ + queryClient: input.queryClient, + serverId: input.serverId, + cwd, + previous: previous?.entries, + current: current.entries, + }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { @@ -655,7 +657,7 @@ function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null { const mode = value.mode; const baseRef = value.baseRef; const ignoreWhitespace = value.ignoreWhitespace; - if (mode !== "uncommitted" && mode !== "base") { + if (mode !== "uncommitted" && mode !== "base" && mode !== "all") { return null; } if (baseRef !== undefined && typeof baseRef !== "string") { diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 2a645f8666..e2b25d7b29 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -27,7 +27,7 @@ type ReplicaQueryInput type FetchQueryInput = Omit< UseQueryOptions, - "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" + "initialData" | "placeholderData" | "queryFn" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; @@ -113,7 +113,7 @@ export function fetchQueryOptions< dataShape, }, }, - refetchOnMount: immutableWhen ? true : "always", + refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"), staleTime: immutableWhen ? (query) => query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 609aa9add4..b5422b71bc 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; -import { useFocusEffect } from "@react-navigation/native"; +import { useFocusEffect } from "expo-router/react-navigation"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUpRight, Check, Terminal } from "lucide-react-native"; import { Button } from "@/components/ui/button"; diff --git a/packages/app/src/desktop/daemon/local-credential.ts b/packages/app/src/desktop/daemon/local-credential.ts index 005abdc21f..78093c8e63 100644 --- a/packages/app/src/desktop/daemon/local-credential.ts +++ b/packages/app/src/desktop/daemon/local-credential.ts @@ -10,6 +10,7 @@ function connectionListen(connection: HostConnection): string | null { case "directPipe": return `pipe://${connection.path}`; case "remoteSsh": + case "routed": case "relay": return null; } diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 8440221ec7..080da9c37a 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -80,7 +80,7 @@ export interface DesktopEditorOpenTargetInput { } export interface DesktopEditorBridge { - listTargets?: () => Promise; + listTargets?: (input?: { workspacePath?: string }) => Promise; openTarget?: (input: DesktopEditorOpenTargetInput) => Promise; } diff --git a/packages/app/src/fdroid/expo-notifications.ts b/packages/app/src/fdroid/expo-notifications.ts index dfd4908c2c..3a8e30fc96 100644 --- a/packages/app/src/fdroid/expo-notifications.ts +++ b/packages/app/src/fdroid/expo-notifications.ts @@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() { export async function getLastNotificationResponseAsync() { return null; } + +export function clearLastNotificationResponse(): void {} diff --git a/packages/app/src/file-explorer/read-result.test.ts b/packages/app/src/file-explorer/read-result.test.ts deleted file mode 100644 index fa5d3bb882..0000000000 --- a/packages/app/src/file-explorer/read-result.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import { describe, expect, it } from "vitest"; -import { explorerFileFromReadResult } from "./read-result"; - -function textRead(bytes: Uint8Array): FileReadResult { - return { - bytes, - mime: "text/plain", - size: bytes.byteLength, - path: "notes.txt", - kind: "text", - modifiedAt: "2026-07-21T00:00:00.000Z", - }; -} - -describe("explorerFileFromReadResult", () => { - it("records and hides a leading UTF-8 BOM", () => { - const file = explorerFileFromReadResult( - textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])), - ); - - expect(file).toMatchObject({ content: "hi", hasBom: true }); - }); - - it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => { - const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi"))); - const embedded = explorerFileFromReadResult( - textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])), - ); - - expect(plain.hasBom).toBe(false); - expect(embedded.hasBom).toBe(false); - }); -}); diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts deleted file mode 100644 index 2e36add959..0000000000 --- a/packages/app/src/file-explorer/read-result.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { ExplorerFile } from "@/stores/session-store"; - -export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { - const isText = file.kind === "text"; - return { - path: file.path, - kind: file.kind, - encoding: isText ? "utf-8" : "none", - content: isText ? new TextDecoder().decode(file.bytes) : undefined, - hasBom: isText && hasUtf8Bom(file.bytes), - mimeType: file.mime, - size: file.size, - modifiedAt: file.modifiedAt, - revision: file.revision, - }; -} - -function hasUtf8Bom(bytes: Uint8Array): boolean { - return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; -} diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts deleted file mode 100644 index e2f6ea4dbd..0000000000 --- a/packages/app/src/file-pane/editor/model.test.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; -import { - FileEditorModel, - getFileConflictCallout, - type FileEditorClock, - type FileEditorFile, - type FileEditorObservation, - type FileEditorSession, - type FileObservationSource, -} from "./model"; - -class TestClock implements FileEditorClock { - private callback: (() => void) | null = null; - - setTimeout(callback: () => void): ReturnType { - this.callback = callback; - return 1 as unknown as ReturnType; - } - - clearTimeout(): void { - this.callback = null; - } - - fire(): void { - const callback = this.callback; - this.callback = null; - callback?.(); - } -} - -class FileSession implements FileEditorSession { - file: FileEditorFile; - writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = []; - nextWrite: FileWriteResult | Error | null = null; - private pendingWrite: Promise | null = null; - private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null; - - constructor(file: FileEditorFile) { - this.file = file; - } - - async write(input: { - content: string; - expectedModifiedAt: string; - expectedRevision?: string; - }): Promise { - this.writes.push(input); - if (this.pendingWrite) return this.pendingWrite; - if (this.nextWrite instanceof Error) throw this.nextWrite; - if (this.nextWrite) return this.nextWrite; - return { - status: "written", - modifiedAt: "2026-07-18T00:00:01.000Z", - size: input.content.length, - }; - } - - holdNextWrite(): void { - this.pendingWrite = new Promise((resolve) => { - this.resolvePendingWrite = resolve; - }); - } - - finishHeldWrite(result: FileWriteResult): void { - this.resolvePendingWrite?.(result); - this.pendingWrite = null; - this.resolvePendingWrite = null; - } -} - -class ObservationSource implements FileObservationSource { - observation: FileEditorObservation | null; - refreshes = 0; - private readonly listeners = new Set<() => void>(); - - constructor(observation: FileEditorObservation | null) { - this.observation = observation; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - getObservation(): FileEditorObservation | null { - return this.observation; - } - - refresh = (): void => { - this.refreshes += 1; - }; - - emit(observation: FileEditorObservation): void { - this.observation = observation; - for (const listener of this.listeners) listener(); - } -} - -function ready( - modifiedAt = "2026-07-18T00:00:00.000Z", - size = 3, -): Extract { - return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt }; -} - -interface MakeModelInput { - content?: string; - hasBom?: boolean; -} - -function makeModel(input: MakeModelInput = {}) { - const file = { - content: input.content ?? "one", - hasBom: input.hasBom ?? false, - version: ready() as Extract, - }; - const session = new FileSession(file); - const clock = new TestClock(); - return { model: new FileEditorModel({ file, session, clock }), session, clock }; -} - -function observeFile(model: FileEditorModel, file: FileEditorFile): void { - model.receiveFileObservation({ status: "ready", file }); -} - -function observeVersion(model: FileEditorModel, version: FileEditorObservation): void { - model.receiveFileObservation(version); -} - -describe("FileEditorModel", () => { - test("tracks whether the current buffer differs from persisted content", async () => { - const { model } = makeModel(); - - expect(model.getSnapshot().modified).toBe(false); - model.edit("two"); - expect(model.getSnapshot().modified).toBe(true); - model.edit("one"); - expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); - - model.edit("saved"); - await model.save(); - expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); - }); - - test("adopts a precise revision for otherwise unchanged initial metadata", () => { - const { model } = makeModel(); - - observeFile(model, { - content: "one", - hasBom: false, - version: { ...ready(), revision: "precise-revision" }, - }); - - expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" }); - }); - - test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => { - const { model, session } = makeModel(); - model.edit("local"); - observeFile(model, { - content: "one", - hasBom: false, - version: { ...ready(), revision: "replacement-revision" }, - }); - - expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" }); - await model.save(); - - expect(session.writes).toEqual([ - { - content: "local", - expectedModifiedAt: "2026-07-18T00:00:00.000Z", - expectedRevision: "replacement-revision", - }, - ]); - }); - - test("adopts an external BOM change before saving a dirty buffer", async () => { - const { model, session } = makeModel(); - model.edit("local"); - observeFile(model, { - content: "one", - hasBom: true, - version: { ...ready(), revision: "replacement-revision" }, - }); - - await model.save(); - - expect(session.writes).toEqual([ - { - content: "\uFEFFlocal", - expectedModifiedAt: "2026-07-18T00:00:00.000Z", - expectedRevision: "replacement-revision", - }, - ]); - }); - - test("conflicts when a same-content observation changes the BOM during a save", async () => { - const { model, session } = makeModel(); - session.holdNextWrite(); - model.edit("saved"); - const save = model.save(); - observeFile(model, { - content: "saved", - hasBom: true, - version: ready("2026-07-18T00:00:02.000Z", 6), - }); - session.finishHeldWrite({ - status: "written", - modifiedAt: "2026-07-18T00:00:01.000Z", - size: 5, - }); - - await save; - - expect(model.getSnapshot()).toMatchObject({ - status: "conflict", - observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, - }); - }); - - test("ignores a settled observation that was already consumed", () => { - const { model } = makeModel(); - const observation: FileEditorObservation = { - status: "ready", - file: { content: "one", hasBom: false, version: ready() }, - }; - let emissions = 0; - model.subscribe(() => { - emissions += 1; - }); - model.receiveFileObservation(observation); - const emissionsAfterFirstDelivery = emissions; - - model.receiveFileObservation(observation); - - expect(emissions).toBe(emissionsAfterFirstDelivery); - }); - - test("does not replay the pre-save observation while its refresh is pending", async () => { - const { model } = makeModel(); - const observation: FileEditorObservation = { - status: "ready", - file: { content: "one", hasBom: false, version: ready() }, - }; - model.receiveFileObservation(observation); - model.edit("saved"); - await model.save(); - - model.receiveFileObservation(observation); - - expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" }); - }); - - test("does not reload stale bytes after a write conflict", async () => { - const { model, session } = makeModel(); - session.nextWrite = { - status: "conflict", - version: ready("2026-07-18T00:00:02.000Z", 8), - }; - model.edit("important local work"); - await model.save(); - - await model.reload(); - - expect(model.getSnapshot()).toMatchObject({ - status: "conflict", - content: "important local work", - observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, - }); - }); - - test("reloads a write conflict only after refreshed bytes arrive", async () => { - const { model, session } = makeModel(); - const source = new ObservationSource({ - status: "ready", - file: { content: "one", hasBom: false, version: ready() }, - }); - model.connectFileObservations(source); - session.nextWrite = { - status: "conflict", - version: ready("2026-07-18T00:00:02.000Z", 4), - }; - model.edit("local"); - await model.save(); - - await model.reload(); - expect(source.refreshes).toBe(1); - expect(model.getSnapshot().content).toBe("local"); - source.emit({ - status: "ready", - file: { - content: "disk", - hasBom: false, - version: ready("2026-07-18T00:00:02.000Z", 4), - }, - }); - - expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); - }); - - test("abandons a deferred reload when its refresh fails", async () => { - const { model, session } = makeModel(); - const source = new ObservationSource({ - status: "ready", - file: { content: "one", hasBom: false, version: ready() }, - }); - model.connectFileObservations(source); - session.nextWrite = { - status: "conflict", - version: ready("2026-07-18T00:00:02.000Z", 4), - }; - model.edit("local"); - await model.save(); - await model.reload(); - source.emit({ - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "File unavailable.", - }); - model.edit("new local work"); - - source.emit({ - status: "ready", - file: { - content: "disk", - hasBom: false, - version: ready("2026-07-18T00:00:03.000Z", 4), - }, - }); - - expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" }); - }); - - test("keeps a newer edit modified when an older save finishes", async () => { - const { model, session } = makeModel(); - session.holdNextWrite(); - model.edit("saving"); - - const save = model.save(); - model.edit("newer edit"); - session.finishHeldWrite({ - status: "written", - modifiedAt: "2026-07-18T00:00:01.000Z", - size: 6, - }); - await save; - - expect(model.getSnapshot()).toMatchObject({ - status: "dirty", - content: "newer edit", - modified: true, - }); - }); - - test("autosaves the latest edit after inactivity", async () => { - const { model, session, clock } = makeModel(); - - model.edit("two"); - model.edit("three"); - clock.fire(); - await Promise.resolve(); - - expect(session.writes).toEqual([ - { content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" }, - ]); - expect(model.getSnapshot().status).toBe("clean"); - }); - - test("keeps CRLF content in file form", async () => { - const { model, session } = makeModel({ content: "one\r\ntwo\r\n" }); - - expect(model.getSnapshot()).toMatchObject({ - content: "one\r\ntwo\r\n", - lineSeparator: "\r\n", - }); - model.edit("one\r\ntwo\r\nthree\r\n"); - await model.save(); - - expect(session.writes).toEqual([ - { - content: "one\r\ntwo\r\nthree\r\n", - expectedModifiedAt: "2026-07-18T00:00:00.000Z", - }, - ]); - }); - - test("restores a UTF-8 BOM before writing a CRLF file", async () => { - const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); - - model.edit("saved\r\n"); - await model.save(); - model.edit("saved again\r\n"); - await model.save(); - - expect(session.writes).toEqual([ - { - content: "\uFEFFsaved\r\n", - expectedModifiedAt: "2026-07-18T00:00:00.000Z", - }, - { - content: "\uFEFFsaved again\r\n", - expectedModifiedAt: "2026-07-18T00:00:01.000Z", - }, - ]); - }); - - test("uses the first line separator when a file mixes styles", () => { - const { model } = makeModel({ content: "one\r\ntwo\nthree\r" }); - - expect(model.getSnapshot().lineSeparator).toBe("\r\n"); - }); - - test("reloads a clean editor when the disk version changes", async () => { - const { model, session } = makeModel(); - session.file = { - content: "external", - hasBom: false, - version: ready("2026-07-18T00:00:02.000Z", 8) as Extract, - }; - - observeFile(model, session.file); - - expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" }); - }); - - test("adopts the format from a clean remote refresh", async () => { - const { model, session } = makeModel({ content: "local\r\n", hasBom: true }); - session.file = { - content: "remote\n", - hasBom: false, - version: ready("2026-07-18T00:00:02.000Z", 7) as Extract, - }; - - observeFile(model, session.file); - expect(model.getSnapshot().lineSeparator).toBe("\n"); - model.edit("saved\n"); - await model.save(); - - expect(session.writes.at(-1)).toEqual({ - content: "saved\n", - expectedModifiedAt: "2026-07-18T00:00:02.000Z", - }); - }); - - test("applies consecutive clean disk observations", () => { - const { model } = makeModel(); - const firstVersion = ready("2026-07-18T00:00:02.000Z", 5); - const latestVersion = ready("2026-07-18T00:00:03.000Z", 6); - - observeFile(model, { content: "first", hasBom: false, version: firstVersion }); - observeFile(model, { content: "latest", hasBom: false, version: latestVersion }); - - expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" }); - }); - - test("preserves a dirty buffer and overwrites against the newest disk revision", async () => { - const { model, session } = makeModel(); - model.edit("local"); - observeFile(model, { - content: "disk", - hasBom: false, - version: ready("2026-07-18T00:00:02.000Z", 4), - }); - - expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" }); - await model.overwrite(); - - expect(session.writes).toEqual([ - { content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" }, - ]); - expect(model.getSnapshot().status).toBe("clean"); - }); - - test("keeps the local CRLF and BOM when overwriting a conflict", async () => { - const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); - model.edit("local\r\n"); - observeFile(model, { - content: "disk", - hasBom: false, - version: ready("2026-07-18T00:00:02.000Z", 4), - }); - - await model.overwrite(); - - expect(session.writes).toEqual([ - { - content: "\uFEFFlocal\r\n", - expectedModifiedAt: "2026-07-18T00:00:02.000Z", - }, - ]); - }); - - test("reload discards a conflicted local buffer for the disk contents", async () => { - const { model, session } = makeModel(); - model.edit("local"); - const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract< - FileVersion, - { status: "ready" } - >; - session.file = { content: "disk", hasBom: false, version: diskVersion }; - observeFile(model, session.file); - - await model.reload(); - - expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); - }); - - test("adopts the remote format when reloading a conflict", async () => { - const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); - model.edit("local\r\n"); - const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract< - FileVersion, - { status: "ready" } - >; - session.file = { content: "disk\n", hasBom: false, version: diskVersion }; - observeFile(model, session.file); - - await model.reload(); - model.edit("saved\n"); - await model.save(); - - expect(session.writes.at(-1)).toEqual({ - content: "saved\n", - expectedModifiedAt: "2026-07-18T00:00:02.000Z", - }); - }); - - test("reports failed saves without losing the local buffer", async () => { - const { model, session } = makeModel(); - session.nextWrite = new Error("disk full"); - model.edit("important local work"); - - await model.save(); - - expect(model.getSnapshot()).toMatchObject({ - status: "error", - content: "important local work", - error: "disk full", - }); - }); - - test("a deletion conflicts with local changes and stops autosave", () => { - const { model, session, clock } = makeModel(); - model.edit("local"); - observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" }); - - clock.fire(); - - expect(model.getSnapshot().status).toBe("conflict"); - expect(session.writes).toEqual([]); - }); - - test("clears a transient check error when the recovered file is unchanged", () => { - const { model } = makeModel(); - observeVersion(model, { - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "Requested path is not a file", - }); - - observeFile(model, { content: "one", hasBom: false, version: ready() }); - - expect(model.getSnapshot()).toMatchObject({ - status: "clean", - modified: false, - observedVersion: { status: "ready" }, - error: null, - }); - }); - - test("resumes autosave when a dirty file recovers unchanged from a check error", async () => { - const { model, session, clock } = makeModel(); - model.edit("local"); - observeVersion(model, { - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "Requested path is not a file", - }); - - observeFile(model, { content: "one", hasBom: false, version: ready() }); - clock.fire(); - await Promise.resolve(); - - expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); - expect(session.writes).toEqual([ - { content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" }, - ]); - }); - - test("dispose cancels pending autosave", () => { - const { model, session, clock } = makeModel(); - model.edit("local"); - - model.dispose(); - clock.fire(); - - expect(session.writes).toEqual([]); - }); - - test("suspends a pending autosave while close confirmation is active", async () => { - const { model, session, clock } = makeModel(); - model.edit("local"); - - const resume = model.suspendAutosave(); - clock.fire(); - expect(session.writes).toEqual([]); - - resume(); - clock.fire(); - await Promise.resolve(); - expect(session.writes).toHaveLength(1); - }); - - test("maps conflict versions to one exhaustive callout state", () => { - const { model } = makeModel(); - const snapshot = model.getSnapshot(); - - expect(getFileConflictCallout(snapshot)).toBeNull(); - expect( - getFileConflictCallout({ - ...snapshot, - status: "conflict", - modified: true, - observedVersion: ready("2026-07-18T00:00:01.000Z"), - }), - ).toEqual({ kind: "changed", canOverwrite: true }); - expect( - getFileConflictCallout({ - ...snapshot, - status: "conflict", - observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" }, - }), - ).toEqual({ kind: "deleted" }); - expect( - getFileConflictCallout({ - ...snapshot, - status: "conflict", - observedVersion: { - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "unreadable", - }, - }), - ).toEqual({ kind: "checkFailed" }); - }); -}); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts deleted file mode 100644 index 6207e28890..0000000000 --- a/packages/app/src/file-pane/editor/model.ts +++ /dev/null @@ -1,402 +0,0 @@ -import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; - -export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error"; -export type FileLineSeparator = "\n" | "\r\n" | "\r"; - -export interface FileEditorSnapshot { - status: FileEditorStatus; - content: string; - lineSeparator: FileLineSeparator; - modified: boolean; - version: FileVersion; - observedVersion: FileVersion; - error: string | null; -} - -export type FileConflictCallout = - | { kind: "changed"; canOverwrite: boolean } - | { kind: "deleted" } - | { kind: "checkFailed" }; - -export interface FileEditorFile { - content: string; - hasBom: boolean; - version: Extract; -} - -export interface FileEditorSession { - write(input: { - content: string; - expectedModifiedAt: string; - expectedRevision?: string; - }): Promise; -} - -export type FileEditorObservation = - | { status: "ready"; file: FileEditorFile } - | Extract; - -export interface FileObservationSource { - subscribe(listener: () => void): () => void; - getObservation(): FileEditorObservation | null; - refresh(): void; -} - -type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion }; - -export interface FileEditorClock { - setTimeout(callback: () => void, delayMs: number): ReturnType; - clearTimeout(handle: ReturnType): void; -} - -const systemClock: FileEditorClock = { - setTimeout(callback, delay) { - return globalThis.setTimeout(callback, delay); - }, - clearTimeout(handle) { - globalThis.clearTimeout(handle); - }, -}; - -export class FileEditorModel { - private readonly session: FileEditorSession; - private readonly clock: FileEditorClock; - private readonly listeners = new Set<() => void>(); - private snapshot: FileEditorSnapshot; - private autosave: ReturnType | null = null; - private saveSequence = 0; - private disposed = false; - private observedWhileSaving: FileEditorObservation | null = null; - private observed: ObservedDiskState; - private lastReceivedObservation: FileEditorObservation | null = null; - private refreshObservation: (() => void) | null = null; - private reloadRequested = false; - private persistedContent: string; - private hasBom: boolean; - private unsubscribeObservationSource: (() => void) | null = null; - - constructor(input: { - file: FileEditorFile; - session: FileEditorSession; - clock?: FileEditorClock; - }) { - this.session = input.session; - this.clock = input.clock ?? systemClock; - this.persistedContent = input.file.content; - this.hasBom = input.file.hasBom; - this.observed = { status: "ready", file: input.file }; - this.snapshot = { - status: "clean", - content: input.file.content, - lineSeparator: detectLineSeparator(input.file.content), - modified: false, - version: input.file.version, - observedVersion: input.file.version, - error: null, - }; - } - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - getSnapshot = (): FileEditorSnapshot => this.snapshot; - - connectFileObservations(source: FileObservationSource): void { - this.disconnectFileObservations(); - this.refreshObservation = source.refresh; - const receiveObservation = () => { - const observation = source.getObservation(); - if (observation) this.receiveFileObservation(observation); - }; - this.unsubscribeObservationSource = source.subscribe(receiveObservation); - receiveObservation(); - } - - disconnectFileObservations(): void { - this.unsubscribeObservationSource?.(); - this.unsubscribeObservationSource = null; - this.refreshObservation = null; - } - - edit(content: string): void { - if (this.disposed || content === this.snapshot.content) return; - this.reloadRequested = false; - const modified = content !== this.persistedContent; - let status: FileEditorStatus = modified ? "dirty" : "clean"; - if (this.snapshot.status === "conflict") { - status = "conflict"; - } - this.setSnapshot({ ...this.snapshot, status, content, modified, error: null }); - if (status === "dirty") this.scheduleAutosave(); - else this.clearAutosave(); - } - - async save(): Promise { - if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) { - return; - } - if (this.snapshot.observedVersion.status !== "ready") { - this.enterConflict(this.snapshot.observedVersion); - return; - } - await this.performWrite(this.snapshot.observedVersion); - } - - receiveFileObservation(observation: FileEditorObservation): void { - if (this.disposed || observation === this.lastReceivedObservation) return; - this.lastReceivedObservation = observation; - const version = observationVersion(observation); - this.observed = observation; - this.setSnapshot({ ...this.snapshot, observedVersion: version }); - if (this.snapshot.status === "saving") { - this.observedWhileSaving = observation; - return; - } - if (observation.status !== "ready") { - this.reloadRequested = false; - this.enterConflict(version); - return; - } - if (this.reloadRequested) { - this.reloadRequested = false; - this.applyFile(observation.file); - return; - } - if (observation.file.content === this.persistedContent) { - this.adoptUnchangedFile(observation.file); - return; - } - if (this.snapshot.status === "clean") { - this.applyFile(observation.file); - return; - } - this.enterConflict(version); - } - - async overwrite(): Promise { - if (this.disposed || this.snapshot.status !== "conflict") return; - if (this.snapshot.observedVersion.status !== "ready") return; - await this.performWrite(this.snapshot.observedVersion); - } - - async reload(): Promise { - if (this.disposed) return; - if (this.observed.status !== "ready") { - this.reloadRequested = true; - this.refreshObservation?.(); - return; - } - this.applyFile(this.observed.file); - } - - dispose(): void { - this.disposed = true; - this.reloadRequested = false; - this.saveSequence += 1; - this.clearAutosave(); - this.disconnectFileObservations(); - this.listeners.clear(); - } - - suspendAutosave(): () => void { - const wasScheduled = this.autosave !== null; - this.clearAutosave(); - let resumed = false; - return () => { - if (resumed || this.disposed) return; - resumed = true; - if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave(); - }; - } - - private async performWrite( - expectedVersion: Extract, - ): Promise { - this.clearAutosave(); - const sequence = ++this.saveSequence; - const content = this.snapshot.content; - const hasBom = this.hasBom; - this.observedWhileSaving = null; - this.setSnapshot({ ...this.snapshot, status: "saving", error: null }); - const serializedContent = hasBom ? `\uFEFF${content}` : content; - let result: FileWriteResult; - try { - result = await this.session.write({ - content: serializedContent, - expectedModifiedAt: expectedVersion.modifiedAt, - expectedRevision: expectedVersion.revision, - }); - } catch (error) { - if (this.disposed || sequence !== this.saveSequence) return; - this.setSnapshot({ - ...this.snapshot, - status: "error", - error: error instanceof Error ? error.message : String(error), - }); - return; - } - if (this.disposed || sequence !== this.saveSequence) return; - if (result.status === "error") { - this.setSnapshot({ ...this.snapshot, status: "error", error: result.error }); - return; - } - if (result.status === "conflict") { - this.observed = { status: "unsettled", version: result.version }; - this.enterConflict(result.version); - return; - } - - const writtenVersion: Extract = { - status: "ready", - cwd: this.snapshot.version.cwd, - path: this.snapshot.version.path, - size: result.size, - modifiedAt: result.modifiedAt, - revision: result.revision, - }; - const pending = this.takeObservedWhileSaving(); - this.persistedContent = content; - if (pending && !observationMatchesWrite(pending, content, hasBom)) { - const pendingVersion = observationVersion(pending); - this.observed = pending; - this.setSnapshot({ - ...this.snapshot, - status: "conflict", - modified: this.snapshot.content !== this.persistedContent, - version: writtenVersion, - observedVersion: pendingVersion, - error: null, - }); - return; - } - const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion; - this.observed = pending ?? { status: "unsettled", version: writtenVersion }; - const modified = this.snapshot.content !== this.persistedContent; - this.setSnapshot({ - ...this.snapshot, - status: modified ? "dirty" : "clean", - modified, - version: settledVersion, - observedVersion: settledVersion, - error: null, - }); - if (modified) this.scheduleAutosave(); - } - - private applyFile(file: FileEditorFile): void { - this.clearAutosave(); - this.saveSequence += 1; - this.persistedContent = file.content; - this.hasBom = file.hasBom; - this.observed = { status: "ready", file }; - this.setSnapshot({ - status: "clean", - content: file.content, - lineSeparator: detectLineSeparator(file.content), - modified: false, - version: file.version, - observedVersion: file.version, - error: null, - }); - } - - private takeObservedWhileSaving(): FileEditorObservation | null { - const observation = this.observedWhileSaving; - this.observedWhileSaving = null; - return observation; - } - - private enterConflict(version: FileVersion): void { - this.clearAutosave(); - this.setSnapshot({ - ...this.snapshot, - status: "conflict", - modified: this.snapshot.content !== this.persistedContent, - observedVersion: version, - error: version.status === "error" ? version.error : null, - }); - } - - private adoptUnchangedFile(file: FileEditorFile): void { - this.hasBom = file.hasBom; - this.observed = { status: "ready", file }; - const modified = this.snapshot.content !== this.persistedContent; - const recovering = this.snapshot.status === "conflict"; - let status = this.snapshot.status; - if (recovering) status = modified ? "dirty" : "clean"; - this.setSnapshot({ - ...this.snapshot, - status, - modified, - version: file.version, - observedVersion: file.version, - error: recovering ? null : this.snapshot.error, - }); - if (status === "dirty") this.scheduleAutosave(); - else this.clearAutosave(); - } - - private scheduleAutosave(): void { - this.clearAutosave(); - this.autosave = this.clock.setTimeout(() => { - this.autosave = null; - void this.save(); - }, 800); - } - - private clearAutosave(): void { - if (!this.autosave) return; - this.clock.clearTimeout(this.autosave); - this.autosave = null; - } - - private setSnapshot(snapshot: FileEditorSnapshot): void { - this.snapshot = snapshot; - for (const listener of this.listeners) listener(); - } -} - -export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null { - if (snapshot.status !== "conflict") return null; - switch (snapshot.observedVersion.status) { - case "ready": - return { kind: "changed", canOverwrite: snapshot.modified }; - case "missing": - return { kind: "deleted" }; - case "error": - return { kind: "checkFailed" }; - default: - return assertNever(snapshot.observedVersion); - } -} - -function assertNever(value: never): never { - throw new Error(`Unexpected file version: ${JSON.stringify(value)}`); -} - -function detectLineSeparator(content: string): FileLineSeparator { - for (let index = 0; index < content.length; index += 1) { - const character = content.charCodeAt(index); - if (character === 10) return "\n"; - if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r"; - } - return "\n"; -} - -function observationVersion(observation: FileEditorObservation): FileVersion { - return observation.status === "ready" ? observation.file.version : observation; -} - -function observationMatchesWrite( - observation: FileEditorObservation, - content: string, - hasBom: boolean, -): boolean { - return ( - observation.status === "ready" && - observation.file.content === content && - observation.file.hasBom === hasBom - ); -} diff --git a/packages/app/src/file-pane/editor/observation-source.test.ts b/packages/app/src/file-pane/editor/observation-source.test.ts deleted file mode 100644 index 30a751b5c1..0000000000 --- a/packages/app/src/file-pane/editor/observation-source.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import { describe, expect, test } from "vitest"; -import type { LiveFileObservation } from "../live-file/model"; -import { createFileObservationSource, type LiveObservationSource } from "./observation-source"; - -class TestLiveObservationSource implements LiveObservationSource { - observation: LiveFileObservation | null; - - constructor(observation: LiveFileObservation | null) { - this.observation = observation; - } - - subscribe(): () => void { - return () => undefined; - } - - getObservation(): LiveFileObservation | null { - return this.observation; - } - - refresh(): void {} -} - -function readyObservation(): LiveFileObservation { - const file: FileReadResult = { - bytes: new TextEncoder().encode("one"), - mime: "text/plain", - size: 3, - path: "file.ts", - kind: "text", - modifiedAt: "2026-07-18T00:00:00.000Z", - revision: "revision-1", - }; - return { - status: "ready", - file, - version: { - status: "ready", - cwd: "/workspace", - path: "file.ts", - size: file.size, - modifiedAt: file.modifiedAt, - revision: file.revision, - }, - }; -} - -describe("createFileObservationSource", () => { - test("returns the same editor observation until the live observation changes", () => { - const liveFile = new TestLiveObservationSource(readyObservation()); - const source = createFileObservationSource(liveFile); - - const first = source.getObservation(); - const repeated = source.getObservation(); - - expect(repeated).toBe(first); - liveFile.observation = readyObservation(); - expect(source.getObservation()).not.toBe(first); - }); -}); diff --git a/packages/app/src/file-pane/editor/observation-source.ts b/packages/app/src/file-pane/editor/observation-source.ts deleted file mode 100644 index 478c5d3e76..0000000000 --- a/packages/app/src/file-pane/editor/observation-source.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { explorerFileFromReadResult } from "@/file-explorer/read-result"; -import type { LiveFileObservation } from "../live-file/model"; -import type { FileEditorObservation, FileObservationSource } from "./model"; - -export interface LiveObservationSource { - subscribe(listener: () => void): () => void; - getObservation(): LiveFileObservation | null; - refresh(): void; -} - -export function createFileObservationSource( - liveFile: LiveObservationSource, -): FileObservationSource { - let liveObservation: LiveFileObservation | null = null; - let editorObservation: FileEditorObservation | null = null; - return { - subscribe: liveFile.subscribe, - refresh: liveFile.refresh, - getObservation() { - const nextLiveObservation = liveFile.getObservation(); - if (nextLiveObservation === liveObservation) return editorObservation; - liveObservation = nextLiveObservation; - editorObservation = fileEditorObservation(nextLiveObservation); - return editorObservation; - }, - }; -} - -function fileEditorObservation( - observation: LiveFileObservation | null, -): FileEditorObservation | null { - if (!observation || observation.status !== "ready") return observation; - const decodedFile = explorerFileFromReadResult(observation.file); - if (decodedFile.kind !== "text" || decodedFile.content === undefined) { - return { - status: "error", - cwd: observation.version.cwd, - path: observation.version.path, - error: "File is no longer text.", - }; - } - return { - status: "ready", - file: { - content: decodedFile.content, - hasBom: decodedFile.hasBom, - version: observation.version, - }, - }; -} diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts deleted file mode 100644 index 7e64facbde..0000000000 --- a/packages/app/src/file-pane/live-file/hook.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { LiveFileModel, type LiveFileSession } from "./model"; -import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation"; -import { isWeb } from "@/constants/platform"; - -export function useLiveFile(input: { - client: DaemonClient | null; - cwd: string | null; - path: string | null; - enabled: boolean; - liveUpdates: boolean; -}) { - const [model] = useState(() => new LiveFileModel()); - const session = useMemo(() => { - if (!input.client) return null; - const client = input.client; - return { - subscribe(target, onVersion) { - return client.subscribeFile(target, onVersion); - }, - read(target) { - return client.readFile( - target.cwd, - target.path, - undefined, - SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain, - ); - }, - }; - }, [input.client]); - - useEffect(() => { - if (!input.enabled || !session || !input.cwd || !input.path) { - model.close(); - return; - } - model.open({ - session, - target: { cwd: input.cwd, path: input.path }, - liveUpdates: input.liveUpdates, - }); - return () => model.close(); - }, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]); - - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - return { - file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null, - error: snapshot.read.status === "error" ? snapshot.read.error : null, - // Effects start reads after the first render. An enabled target is therefore - // already in the producer's initial/retargeting display state. - isFetching: - input.enabled && - (snapshot.read.status === "pending" || - (snapshot.read.status === "idle" && snapshot.observation === null)), - isRetrying: snapshot.read.status === "pending" && snapshot.read.requested, - refresh: model.refresh, - model, - snapshot, - }; -} diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts deleted file mode 100644 index c84f73e8ed..0000000000 --- a/packages/app/src/file-pane/live-file/model.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { FileVersion } from "@getpaseo/protocol/messages"; -import { describe, expect, it, vi } from "vitest"; -import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model"; - -interface Deferred { - promise: Promise; - resolve(value: T): void; - reject(error: Error): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((onResolve, onReject) => { - resolve = onResolve; - reject = onReject; - }); - return { promise, resolve, reject }; -} - -function ready(revision = "revision-1"): Extract { - return { - status: "ready", - cwd: "/workspace", - path: "file.ts", - size: 3, - modifiedAt: "2026-07-30T00:00:00.000Z", - revision, - }; -} - -function file(revision = "revision-1", content = "one"): FileReadResult { - return { - bytes: new TextEncoder().encode(content), - mime: "text/plain", - size: content.length, - path: "file.ts", - kind: "text", - modifiedAt: "2026-07-30T00:00:00.000Z", - revision, - }; -} - -class TestLiveFileSession implements LiveFileSession { - readonly reads: Deferred[] = []; - subscribeAttempts = 0; - private onVersion: ((version: FileVersion) => void) | null = null; - private readonly initial: FileVersion; - - constructor(initial: FileVersion = ready()) { - this.initial = initial; - } - - async subscribe( - _target: { cwd: string; path: string }, - onVersion: (version: FileVersion) => void, - ): Promise { - this.subscribeAttempts += 1; - this.onVersion = onVersion; - return { initial: this.initial, unsubscribe: () => (this.onVersion = null) }; - } - - read(): Promise { - const request = deferred(); - this.reads.push(request); - return request.promise; - } - - emit(version: FileVersion): void { - this.onVersion?.(version); - } -} - -class DeferredSubscribeSession implements LiveFileSession { - readonly reads: Deferred[] = []; - readonly subscription = deferred(); - private onVersion: ((version: FileVersion) => void) | null = null; - - subscribe( - _target: { cwd: string; path: string }, - onVersion: (version: FileVersion) => void, - ): Promise { - this.onVersion = onVersion; - return this.subscription.promise; - } - - read(): Promise { - const request = deferred(); - this.reads.push(request); - return request.promise; - } - - emit(version: FileVersion): void { - this.onVersion?.(version); - } -} - -class FlakySubscriptionSession extends TestLiveFileSession { - override async subscribe( - target: { cwd: string; path: string }, - onVersion: (version: FileVersion) => void, - ): Promise { - if (this.subscribeAttempts === 0) { - this.subscribeAttempts += 1; - throw new Error("Connection closed."); - } - return super.subscribe(target, onVersion); - } -} - -async function waitForRead( - session: { reads: Deferred[] }, - count: number, -): Promise { - await vi.waitFor(() => expect(session.reads).toHaveLength(count)); -} - -describe("LiveFileModel", () => { - it("reports initial and read-pending work before any terminal observation", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false }); - await waitForRead(session, 1); - expect(model.getSnapshot()).toEqual({ - observation: null, - read: { status: "pending", requested: false }, - }); - }); - - it("reports read error as terminal and recovers on refresh", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false }); - await waitForRead(session, 1); - session.reads[0].reject(new Error("read failed")); - await vi.waitFor(() => - expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }), - ); - model.refresh(); - await waitForRead(session, 2); - session.reads[1].resolve(file("revision-2")); - await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); - }); - it("does not publish a missing candidate when the following read succeeds", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); - const publishedStatuses: string[] = []; - model.subscribe(() => { - const observation = model.getObservation(); - if (observation) publishedStatuses.push(observation.status); - }); - - session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); - await waitForRead(session, 2); - session.reads[1].resolve(file()); - await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" })); - - expect(publishedStatuses).not.toContain("missing"); - }); - - it("publishes missing when the following read also fails", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); - - session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); - await waitForRead(session, 2); - session.reads[1].reject(new Error("File not found.")); - - await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing")); - }); - - it("preserves a missing candidate for its queued confirming read", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - - session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); - session.reads[0].resolve(file()); - await waitForRead(session, 2); - session.reads[1].reject(new Error("File not found.")); - - await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing")); - }); - - it("recovers only from a read begun after a missing observation", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); - - model.refresh(); - await waitForRead(session, 2); - session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); - session.reads[1].resolve(file("revision-2", "two")); - - await waitForRead(session, 3); - expect(model.getObservation()?.status).toBe("ready"); - expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); - - session.reads[2].resolve(file("revision-2", "two")); - await vi.waitFor(() => { - expect(model.getSnapshot()).toMatchObject({ - observation: { - status: "ready", - version: { revision: "revision-2" }, - file: { revision: "revision-2" }, - }, - read: { status: "idle" }, - }); - }); - }); - - it("keeps an actionable error visible while refresh fails", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); - - session.emit({ - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "Requested path is not a file", - }); - await waitForRead(session, 2); - expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false }); - session.reads[1].reject(new Error("File unavailable.")); - - await vi.waitFor(() => { - expect(model.getSnapshot()).toMatchObject({ - observation: { status: "error", error: "Requested path is not a file" }, - read: { status: "error", error: "File unavailable." }, - }); - }); - - model.refresh(); - await waitForRead(session, 3); - expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); - }); - - it("keeps an update that arrives before the subscription response", async () => { - const session = new DeferredSubscribeSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - - session.emit(ready("revision-2")); - session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} }); - - await waitForRead(session, 1); - expect(model.getObservation()).toBeNull(); - }); - - it("retries a failed subscription when the user refreshes", async () => { - const session = new FlakySubscriptionSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - expect(session.subscribeAttempts).toBe(1); - - session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); - model.refresh(); - - await waitForRead(session, 2); - expect(session.subscribeAttempts).toBe(2); - expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); - }); - - it("reads once without subscribing when live updates are disabled", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: false, - }); - - await waitForRead(session, 1); - expect(session.subscribeAttempts).toBe(0); - expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false }); - }); - - it("ignores a read that completes after the model closes", async () => { - const session = new TestLiveFileSession(); - const model = new LiveFileModel(); - model.open({ - session, - target: { cwd: "/workspace", path: "file.ts" }, - liveUpdates: true, - }); - await waitForRead(session, 1); - - model.close(); - session.reads[0].resolve(file()); - await Promise.resolve(); - - expect(model.getSnapshot()).toEqual({ - observation: null, - read: { status: "idle" }, - }); - }); -}); diff --git a/packages/app/src/file-pane/live-file/model.ts b/packages/app/src/file-pane/live-file/model.ts deleted file mode 100644 index cbd0757490..0000000000 --- a/packages/app/src/file-pane/live-file/model.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { FileVersion } from "@getpaseo/protocol/messages"; - -export interface LiveFileTarget { - cwd: string; - path: string; -} - -export interface LiveFileSubscription { - initial: FileVersion; - unsubscribe(): void; -} - -export interface LiveFileSession { - subscribe( - target: LiveFileTarget, - onVersion: (version: FileVersion) => void, - ): Promise; - read(target: LiveFileTarget): Promise; -} - -export type LiveFileReadState = - | { status: "idle" } - | { status: "pending"; requested: boolean } - | { status: "error"; error: string }; - -export type LiveFileObservation = - | { - status: "ready"; - version: Extract; - file: FileReadResult; - } - | Extract; - -export interface LiveFileSnapshot { - observation: LiveFileObservation | null; - read: LiveFileReadState; -} - -const idleSnapshot: LiveFileSnapshot = { - observation: null, - read: { status: "idle" }, -}; - -export class LiveFileModel { - private readonly listeners = new Set<() => void>(); - private snapshot: LiveFileSnapshot = idleSnapshot; - private session: LiveFileSession | null = null; - private target: LiveFileTarget | null = null; - private liveUpdates = false; - private unsubscribe: (() => void) | null = null; - private subscriptionPending = false; - private generation = 0; - private issuedReadSequence = 0; - private activeReadSequence: number | null = null; - private activeReadRequested = false; - private observedAfterReadSequence: number | null = null; - private candidateVersion: FileVersion | null = null; - private refreshQueued = false; - private requestedRefreshQueued = false; - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - getSnapshot = (): LiveFileSnapshot => this.snapshot; - - getObservation = (): LiveFileObservation | null => this.snapshot.observation; - - open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void { - this.reset(); - this.session = input.session; - this.target = input.target; - this.liveUpdates = input.liveUpdates; - const generation = this.generation; - if (!input.liveUpdates) { - this.scheduleRead(false); - return; - } - void this.startSubscription(generation); - } - - close(): void { - this.reset(); - } - - refresh = (): void => { - if (!this.session || !this.target) return; - if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) { - void this.startSubscription(this.generation); - } - this.scheduleRead(true); - }; - - private scheduleRead(requested: boolean): void { - if (this.activeReadSequence !== null) { - this.refreshQueued = true; - this.activeReadRequested ||= requested; - this.requestedRefreshQueued ||= requested; - if (requested && this.snapshot.read.status === "pending") { - this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } }); - } - return; - } - this.startRead(requested); - } - - private reset(): void { - this.generation += 1; - this.unsubscribe?.(); - this.unsubscribe = null; - this.session = null; - this.target = null; - this.liveUpdates = false; - this.subscriptionPending = false; - this.issuedReadSequence = 0; - this.activeReadSequence = null; - this.activeReadRequested = false; - this.observedAfterReadSequence = null; - this.candidateVersion = null; - this.refreshQueued = false; - this.requestedRefreshQueued = false; - this.setSnapshot(idleSnapshot); - } - - private async startSubscription(generation: number): Promise { - const session = this.session; - const target = this.target; - if (!session || !target) return; - this.subscriptionPending = true; - let receivedUpdate = false; - try { - const subscription = await session.subscribe(target, (version) => { - if (generation !== this.generation) return; - receivedUpdate = true; - this.observeVersion(version); - }); - if (generation !== this.generation) { - subscription.unsubscribe(); - return; - } - this.subscriptionPending = false; - this.unsubscribe = subscription.unsubscribe; - if (!receivedUpdate) this.observeVersion(subscription.initial); - } catch { - if (generation !== this.generation) return; - this.subscriptionPending = false; - this.scheduleRead(false); - } - } - - private observeVersion(version: FileVersion): void { - this.observedAfterReadSequence = this.issuedReadSequence; - this.candidateVersion = version; - this.scheduleRead(false); - } - - private startRead(requested: boolean): void { - const session = this.session; - const target = this.target; - if (!session || !target) return; - const generation = this.generation; - const sequence = ++this.issuedReadSequence; - this.activeReadSequence = sequence; - this.activeReadRequested = requested; - this.refreshQueued = false; - this.requestedRefreshQueued = false; - this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } }); - void session.read(target).then( - (file) => this.finishRead({ generation, sequence, target, file }), - (error) => this.failRead({ generation, sequence, target, error }), - ); - } - - private finishRead(input: { - generation: number; - sequence: number; - target: LiveFileTarget; - file: FileReadResult; - }): void { - if (!this.isActiveRead(input.generation, input.sequence)) return; - const requested = this.activeReadRequested; - this.activeReadSequence = null; - this.activeReadRequested = false; - if (this.readPredatesLatestObservation(input.sequence)) { - this.startRead(requested || this.takeQueuedRequested()); - return; - } - this.observedAfterReadSequence = null; - this.candidateVersion = null; - this.setSnapshot({ - observation: { - status: "ready", - file: input.file, - version: readyVersion(input.target, input.file), - }, - read: { status: "idle" }, - }); - this.startQueuedRead(); - } - - private failRead(input: { - generation: number; - sequence: number; - target: LiveFileTarget; - error: unknown; - }): void { - if (!this.isActiveRead(input.generation, input.sequence)) return; - const requested = this.activeReadRequested; - this.activeReadSequence = null; - this.activeReadRequested = false; - if (this.readPredatesLatestObservation(input.sequence)) { - this.startRead(requested || this.takeQueuedRequested()); - return; - } - const message = errorMessage(input.error); - const candidate = this.candidateVersion; - this.candidateVersion = null; - const candidateDescribesFailure = - candidate?.status === "missing" || candidate?.status === "error"; - const observation: LiveFileObservation = candidateDescribesFailure - ? candidate - : { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; - this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } }); - this.startQueuedRead(); - } - - private isActiveRead(generation: number, sequence: number): boolean { - return generation === this.generation && sequence === this.activeReadSequence; - } - - private readPredatesLatestObservation(sequence: number): boolean { - return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence; - } - - private startQueuedRead(): void { - if (!this.refreshQueued) return; - this.startRead(this.takeQueuedRequested()); - } - - private takeQueuedRequested(): boolean { - const requested = this.requestedRefreshQueued; - this.refreshQueued = false; - this.requestedRefreshQueued = false; - return requested; - } - - private setSnapshot(snapshot: LiveFileSnapshot): void { - if (snapshot === this.snapshot) return; - this.snapshot = snapshot; - for (const listener of this.listeners) listener(); - } -} - -function readyVersion( - target: LiveFileTarget, - file: FileReadResult, -): Extract { - return { - status: "ready", - cwd: target.cwd, - path: target.path, - size: file.size, - modifiedAt: file.modifiedAt, - revision: file.revision, - }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index ea3c5b1edb..5f70030819 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,236 +1,27 @@ -import { Button } from "@/components/ui/button"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { ScrollView as RNScrollView, Text, View } from "react-native"; -import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; +import { usePaneContext } from "@/panels/pane-context"; +import { useEffect, useMemo, useState } from "react"; +import type { PluginFile, PluginTab } from "@getpaseo/plugin/client"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { filePreviewRenderKind } from "@/components/file-pane-render-mode"; -import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; -import { getFileNameFromPath } from "@/attachments/utils"; -import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; -import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import { Button } from "@/components/ui/button"; import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useFileHandle, useFileHandleSnapshot } from "@/file-panels/file"; +import { FilePanelLoading } from "@/file-panels/loading"; +import { FilePanelPicker, FilePanelView, useFilePanelChoice, type FilePanel } from "@/file-panels"; +import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; import { useAppActivelyVisible } from "@/hooks/use-app-visible"; -import { isFileQueryEnabled } from "@/components/file-pane-enabled"; -import { isWeb } from "@/constants/platform"; -import { useAppSettings } from "@/hooks/use-settings"; -import { useLiveFile } from "./live-file/hook"; -import { useFilePreview } from "./preview-lifecycle/hook"; -import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model"; -import { FilePanelBar } from "./bar"; -import { FileHtmlPreview } from "./html-preview"; -import { FileMarkdownPreview } from "./markdown-preview"; -import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; -import { createFileObservationSource } from "./editor/observation-source"; -import { FileEditorView } from "./editor/view"; -import { FileSourceView } from "./source/view"; -import type { FileConflictAlertState } from "./conflict-alert"; -import type { LiveFileModel } from "./live-file/model"; -import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; -import type { Theme } from "@/styles/theme"; -import { ZoomableImage } from "@/components/zoomable-viewport/image"; - -const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); - -interface FilePreviewBodyProps { - preview: ExplorerFile | null; - mode?: "preview" | "source"; - isLoading: boolean; - isMobile: boolean; - location: WorkspaceFileLocation; - navigationRevision: number; - imagePreviewUri: string | null; - workspaceRoot: string; - client: DaemonClient | null; - serverId: string; -} - -type TextExplorerFile = ExplorerFile & { kind: "text" }; - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function formatFileSize({ size }: { size: number }): string { - if (size < 1024) { - return `${size} B`; - } - if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(1)} KB`; - } - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - -function ReadonlySource({ - preview, - filename, - location, - navigationRevision, -}: { - preview: ExplorerFile; - filename: string; - location: WorkspaceFileLocation; - navigationRevision: number; -}) { - const theme = UnistylesRuntime.getTheme(); - const { t } = useTranslation(); - const visualTheme = useMemo( - () => ({ - colorScheme: theme.colorScheme, - background: theme.colors.surface0, - foreground: theme.colors.foreground, - cursor: theme.colors.terminal.cursor, - foregroundMuted: theme.colors.foregroundMuted, - border: theme.colors.border, - selection: theme.colors.terminal.selectionBackground, - monoFont: theme.fontFamily.mono, - codeFontSize: theme.fontSize.code, - syntax: theme.colors.syntax, - }), - [theme], - ); - return ( - - ); -} - -function TooLargeSource({ size }: { size?: number }) { - const { t } = useTranslation(); - return ( - - {t("panels.file.tooLargeToDisplay")} - {size ? {formatFileSize({ size })} : null} - - ); -} - -function FilePreviewBody({ - preview, - mode, - isLoading, - isMobile: _isMobile, - location, - navigationRevision, - imagePreviewUri, - workspaceRoot, - client, - serverId, -}: FilePreviewBodyProps) { - const { t } = useTranslation(); - const filePath = location.path; - // A line target means the caller wants to land on that line, so fall back to - // the highlighted source view even for renderable files. - const renderKind = - preview?.kind === "text" && !location.lineStart && mode !== "source" - ? filePreviewRenderKind(filePath) - : null; - - const previewScrollRef = useRef(null); - - if (isLoading && !preview) { - return ( - - - {t("panels.file.loading")} - - ); - } - - if (!preview) { - return ( - - {t("panels.file.noPreview")} - - ); - } - - if (preview.kind === "text") { - if (renderKind === "html") { - // The HTML document owns its own scrolling, so no ScrollView wrapper here. - return ( - - - - ); - } - - if (renderKind === "markdown") { - return ( - - - - - - ); - } - - return ( - - ); - } - - if (preview.kind === "image") { - if (!imagePreviewUri) { - return ( - - - {t("panels.file.loading")} - - ); - } - - return ; - } - - return ( - - {t("panels.file.binaryPreviewUnavailable")} - {formatFileSize({ size: preview.size })} - - ); -} +import { useSessionStore } from "@/stores/session-store"; +import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; +import { formatFileSize } from "@/utils/format-file-size"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; +/** + * The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows + * its own loading; the tab only knows the file exists, its kind, and whether the panel has + * unsaved changes. + */ export function FilePane({ serverId, workspaceRoot, @@ -243,436 +34,121 @@ export function FilePane({ navigationRevision: number; }) { const { t } = useTranslation(); - const isMobile = useIsCompactFormFactor(); - const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview"); - - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. - const supportsEditing = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true, + const connected = useSessionStore((state) => Boolean(state.sessions[serverId]?.client)); + const root = workspaceRoot.trim(); + const path = location.path.trim(); + const target = useMemo( + () => (path ? resolveFilePreviewReadTarget({ path, workspaceRoot: root }) : null), + [path, root], ); - const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); - const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); - const readTarget = useMemo( - () => - normalizedFilePath - ? resolveFilePreviewReadTarget({ - path: normalizedFilePath, - workspaceRoot: normalizedWorkspaceRoot, - }) - : null, - [normalizedFilePath, normalizedWorkspaceRoot], + const absolutePath = useMemo( + () => buildAbsoluteExplorerPath({ workspaceRoot: root, entryPath: path }), + [path, root], ); - - // Re-read the file when this pane becomes visible again (#445). `isActive` - // covers tab switches; active app visibility covers backgrounding and returning - // from another window after an external edit. The gate lives in isFileQueryEnabled. + // A hidden tab or a backgrounded app stops observing; observing again reports what changed. const isActive = useRetainedPanelActive(); const isAppVisible = useAppActivelyVisible(); - const enabled = isFileQueryEnabled({ - hasReadTarget: Boolean(client && readTarget), - isTabActive: isActive, - isAppVisible, + const handle = useFileHandle({ + serverId, + target, + absolutePath, + observing: isActive && isAppVisible, }); - const liveFile = useLiveFile({ - client, - cwd: readTarget?.cwd ?? null, - path: readTarget?.path ?? null, - enabled, - liveUpdates: supportsEditing, - }); - - const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null; - const previewLifecycle = useFilePreview({ + const snapshot = useFileHandleSnapshot(handle); + const targetKey = target ? `${target.cwd}:${target.path}` : null; + const { panel, choice } = useFilePanelChoice({ + serverId, targetKey, - liveFileSnapshot: liveFile.snapshot, + path, + fileKind: snapshot.status === "ready" ? snapshot.kind : null, + presentable: isActive, + lineNavigation: location.lineStart ? navigationRevision : null, }); - - useEffect(() => setPreviewMode("preview"), [targetKey]); - - const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle); - const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment); - const isRenderable = isRenderablePreview(preview, location.path); - const editable = isEditableTextFile({ - preview, - supportsEditing, - }); - const canTogglePreviewMode = isRenderable && !location.lineStart; - const lineCount = - preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined; - const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null; - const isLoading = - previewLifecycle.status === "initial" || - previewLifecycle.status === "read_pending" || - previewLifecycle.status === "preparing"; - - return ( - + const [modified, setModified] = useState(false); + usePublishPanelInstanceAttributes({ modified, suspendPendingSave: handle?.holdWrites }); + const panelLocation = useMemo( + () => ({ + lineStart: location.lineStart, + lineEnd: location.lineEnd, + path, + }), + [location.lineEnd, location.lineStart, path], ); -} -function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean { - return preview?.kind === "text" && filePreviewRenderKind(path) !== null; -} - -function isEditableTextFile(input: { - preview: ExplorerFile | null; - supportsEditing: boolean; -}): boolean { - return Boolean( - isWeb && - input.supportsEditing && - input.preview?.kind === "text" && - input.preview.size <= 1024 * 1024, - ); -} - -function FilePanePresentation({ - serverId, - workspaceRoot, - client, - readTarget, - preview, - liveFile, - onRetryRead, - retryingRead, - retryLabel, - filename, - previewMode, - onPreviewModeChange, - lineCount, - editable, - disconnectedMessage, - errorMessage, - isLoading, - isMobile, - location, - navigationRevision, - imagePreviewUri, -}: { - serverId: string; - workspaceRoot: string; - client: DaemonClient | null; - readTarget: { cwd: string; path: string } | null; - preview: ExplorerFile | null; - liveFile: LiveFileModel; - onRetryRead: () => void; - retryingRead: boolean; - retryLabel: string; - filename: string; - previewMode?: "preview" | "source"; - onPreviewModeChange?: (mode: "preview" | "source") => void; - lineCount?: number; - editable: boolean; - disconnectedMessage: string; - errorMessage: string | null; - isLoading: boolean; - isMobile: boolean; - location: WorkspaceFileLocation; - navigationRevision: number; - imagePreviewUri: string | null; -}) { - if (!client && readTarget) { + let body; + if (!connected && target) { + body = {t("workspace.terminal.hostDisconnected")}; + } else if (!handle || snapshot.status === "loading") { return ( - - {disconnectedMessage} - + + + ); + } else if (snapshot.status === "missing" || snapshot.status === "error") { + body = ( + <> + + {snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")} + + + + ); + } else if (!panel) { + body = ( + <> + {t("panels.file.binaryPreviewUnavailable")} + {formatFileSize(snapshot.size)} + + ); + } else { + return ( + + + ); } - - if (editable && client && readTarget && preview?.kind === "text") { - return ( - - ); - } - - if (errorMessage) { - if (errorMessage === "File is too large to display") { - return ( - - - - ); - } - return ( - - - {errorMessage} - - - - ); - } - return ( - {preview ? ( - - ) : null} - + {body} ); } -function EditableFilePane({ - serverId, - workspaceRoot, - client, - cwd, - path, - preview, - liveFile, - onRetryRead, - retryingRead, - filename, - mode, - onModeChange, - isLoading, - isMobile, - location, - navigationRevision, +/** One panel mount. Its tab reports unsaved changes until the panel unmounts. */ +function MountedFilePanel({ + panel, + file, + params, + revision, + onModifiedChange, }: { - serverId: string; - workspaceRoot: string; - client: DaemonClient; - cwd: string; - path: string; - preview: TextExplorerFile; - liveFile: LiveFileModel; - onRetryRead: () => void; - retryingRead: boolean; - filename: string; - mode?: "preview" | "source"; - onModeChange?: (mode: "preview" | "source") => void; - isLoading: boolean; - isMobile: boolean; - location: WorkspaceFileLocation; - navigationRevision: number; + panel: FilePanel; + file: PluginFile; + params: { path: string; lineStart?: number; lineEnd?: number }; + revision: number; + onModifiedChange: (modified: boolean) => void; }) { - const { settings } = useAppSettings(); - const { t } = useTranslation(); - const [cursor, setCursor] = useState({ line: 1, column: 1 }); - const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); - const session = useMemo( + const { state, setCurrentTabState } = usePaneContext(); + const tab = useMemo( () => ({ - write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { - return client.writeFile({ cwd, path, ...input }); - }, + setModified: onModifiedChange, + state, + setState: setCurrentTabState, }), - [client, cwd, path], + [onModifiedChange, state, setCurrentTabState], ); - const [model] = useState(() => { - return new FileEditorModel({ - file: { - content: preview.content ?? "", - hasBom: preview.hasBom, - version: { - status: "ready", - cwd, - path, - size: preview.size, - modifiedAt: preview.modifiedAt, - revision: preview.revision, - }, - }, - session, - }); - }); - useEffect(() => { - const source = createFileObservationSource(liveFile); - model.connectFileObservations(source); - return () => model.disconnectFileObservations(); - }, [liveFile, model]); - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); - usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave }); - const theme = UnistylesRuntime.getTheme(); - const visualTheme = useMemo( - () => ({ - colorScheme: theme.colorScheme, - background: theme.colors.surface0, - foreground: theme.colors.foreground, - cursor: theme.colors.terminal.cursor, - foregroundMuted: theme.colors.foregroundMuted, - border: theme.colors.border, - selection: theme.colors.terminal.selectionBackground, - monoFont: theme.fontFamily.mono, - codeFontSize: theme.fontSize.code, - syntax: theme.colors.syntax, - }), - [ - theme.colors.border, - theme.colors.foreground, - theme.colors.foregroundMuted, - theme.colors.surface0, - theme.colors.syntax, - theme.colors.terminal.cursor, - theme.colors.terminal.selectionBackground, - theme.colorScheme, - theme.fontFamily.mono, - theme.fontSize.code, - ], - ); - - useEffect(() => () => model.dispose(), [model]); - - const handleReload = useCallback(() => { - if (!snapshot.modified) { - void model.reload(); - return; - } - void (async () => { - const confirmed = await confirmDialog({ - title: t("panels.file.editor.reloadTitle"), - message: t("panels.file.editor.reloadMessage"), - confirmLabel: t("panels.file.editor.reload"), - destructive: true, - }); - if (confirmed) void model.reload(); - })(); - }, [model, snapshot.modified, t]); - const handleOverwrite = useCallback(() => void model.overwrite(), [model]); - const conflict = fileConflictAlertState({ - callout: getFileConflictCallout(snapshot), - onOverwrite: handleOverwrite, - onReload: handleReload, - onRetry: onRetryRead, - retrying: retryingRead, - }); - const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []); - const renderedPreview = useMemo( - () => ({ - ...preview, - content: snapshot.content, - size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size, - modifiedAt: - snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt, - }), - [preview, snapshot.content, snapshot.version], - ); - const showSource = mode !== "preview"; - - return ( - - - {showSource ? ( - - ) : ( - - )} - - ); -} - -function fileConflictAlertState(input: { - callout: FileConflictCallout | null; - onOverwrite(): void; - onReload(): void; - onRetry(): void; - retrying: boolean; -}): FileConflictAlertState | undefined { - if (!input.callout) return undefined; - if (input.callout.kind === "deleted") return { kind: "deleted" }; - if (input.callout.kind === "checkFailed") { - return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry }; - } - return { - kind: "changed", - canOverwrite: input.callout.canOverwrite, - onReload: input.onReload, - onOverwrite: input.onOverwrite, - }; + useEffect(() => () => onModifiedChange(false), [onModifiedChange]); + return ; } const styles = StyleSheet.create((theme) => ({ @@ -688,35 +164,14 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[3], padding: theme.spacing[4], }, - loadingText: { - marginTop: theme.spacing[2], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, errorText: { color: theme.colors.destructive, fontSize: theme.fontSize.base, textAlign: "center", }, - emptyText: { + mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, textAlign: "center", }, - binaryMetaText: { - marginTop: theme.spacing[2], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, - previewScrollContainer: { - flex: 1, - minHeight: 0, - }, - previewContent: { - flex: 1, - minHeight: 0, - }, - previewCodeScrollContent: { - padding: theme.spacing[4], - }, })); diff --git a/packages/app/src/file-pane/preview-lifecycle/hook.ts b/packages/app/src/file-pane/preview-lifecycle/hook.ts deleted file mode 100644 index 04d8545f73..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/hook.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useState, useSyncExternalStore } from "react"; -import type { LiveFileSnapshot } from "../live-file/model"; -import { - createFilePanePreview, - FilePreviewLifecycleModel, - type FilePreviewLifecycleSnapshot, -} from "./model"; - -/** Bridges the raw-file owner to the preview preparation owner. */ -export function useFilePreview(input: { - targetKey: string | null; - liveFileSnapshot: LiveFileSnapshot; -}): FilePreviewLifecycleSnapshot { - const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview)); - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const { targetKey, liveFileSnapshot } = input; - - useEffect(() => { - model.setSource({ targetKey, liveFileSnapshot }); - }, [liveFileSnapshot, model, targetKey]); - - return snapshot; -} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.test.ts b/packages/app/src/file-pane/preview-lifecycle/model.test.ts deleted file mode 100644 index a934e69b58..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { LiveFileSnapshot } from "../live-file/model"; -import { describe, expect, it } from "vitest"; -import { - FilePreviewLifecycleModel, - type FilePanePreview, - type FilePreviewLifecycleSnapshot, -} from "./model"; - -function previewFile(content: string) { - return { - kind: "text" as const, - path: "file.ts", - size: 7, - encoding: "utf-8" as const, - hasBom: false, - modifiedAt: "2026-08-20T00:00:00.000Z", - content, - }; -} - -interface Deferred { - promise: Promise; - resolve(value: T): void; - reject(error: Error): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((onResolve, onReject) => { - resolve = onResolve; - reject = onReject; - }); - return { promise, resolve, reject }; -} - -function file(revision = "revision-1", path = "file.ts"): FileReadResult { - return { - bytes: new TextEncoder().encode("preview"), - mime: "text/plain", - size: 7, - path, - kind: "text", - modifiedAt: "2026-08-20T00:00:00.000Z", - revision, - }; -} - -function pending(): LiveFileSnapshot { - return { observation: null, read: { status: "pending", requested: false } }; -} - -function completed(rawFile: FileReadResult): LiveFileSnapshot { - return { - observation: { - status: "ready", - file: rawFile, - version: { - status: "ready", - cwd: "/workspace", - path: rawFile.path, - size: rawFile.size, - modifiedAt: rawFile.modifiedAt, - revision: rawFile.revision, - }, - }, - read: { status: "idle" }, - }; -} - -function source( - targetKey: string, - snapshot: LiveFileSnapshot, -): { - targetKey: string; - liveFileSnapshot: LiveFileSnapshot; -} { - return { targetKey, liveFileSnapshot: snapshot }; -} - -describe("FilePreviewLifecycleModel", () => { - it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => { - const first = deferred(); - const second = deferred(); - const third = deferred(); - const preparations = [first, second, third]; - const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise); - const preview: FilePanePreview = { - file: previewFile("preview"), - imageAttachment: null, - }; - - model.setSource(source("/workspace:file.ts", pending())); - expect(model.getSnapshot()).toEqual({ status: "read_pending" }); - - model.setSource(source("/workspace:file.ts", completed(file("one")))); - expect(model.getSnapshot()).toEqual({ status: "preparing" }); - first.resolve(preview); - await expectSnapshot(model, { status: "ready", preview }); - - model.setSource(source("/workspace:file.ts", pending())); - expect(model.getSnapshot()).toEqual({ status: "read_pending", preview }); - - model.setSource( - source("/workspace:file.ts", { - observation: { - status: "error", - cwd: "/workspace", - path: "file.ts", - error: "Requested path is not a file", - }, - read: { status: "pending", requested: true }, - }), - ); - expect(model.getSnapshot()).toEqual({ status: "read_pending", preview }); - - model.setSource(source("/workspace:file.ts", completed(file("two")))); - expect(model.getSnapshot()).toEqual({ status: "preparing", preview }); - second.resolve(null); - await expectSnapshot(model, { status: "unsupported" }); - - model.setSource(source("/workspace:file.ts", completed(file("three")))); - expect(model.getSnapshot()).toEqual({ status: "preparing" }); - third.reject(new Error("attachment failed")); - await expectSnapshot(model, { status: "error", message: "attachment failed" }); - }); - - it("does not publish a stale conversion after retargeting", async () => { - const first = deferred(); - const second = deferred(); - const model = new FilePreviewLifecycleModel((rawFile) => - rawFile.revision === "one" ? first.promise : second.promise, - ); - const nextPreview: FilePanePreview = { - file: previewFile("two"), - imageAttachment: null, - }; - - model.setSource(source("/workspace:file.ts", completed(file("one")))); - model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts")))); - first.resolve({ file: previewFile("one"), imageAttachment: null }); - await Promise.resolve(); - expect(model.getSnapshot()).toEqual({ status: "preparing" }); - - second.resolve(nextPreview); - await expectSnapshot(model, { status: "ready", preview: nextPreview }); - }); -}); - -async function expectSnapshot( - model: FilePreviewLifecycleModel, - expected: FilePreviewLifecycleSnapshot, -): Promise { - await new Promise((resolve) => queueMicrotask(resolve)); - expect(model.getSnapshot()).toEqual(expected); -} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.ts b/packages/app/src/file-pane/preview-lifecycle/model.ts deleted file mode 100644 index fe69f2227b..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { AttachmentMetadata } from "@/attachments/types"; -import { persistAttachmentFromBytes } from "@/attachments/service"; -import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; -import { explorerFileFromReadResult } from "@/file-explorer/read-result"; -import type { ExplorerFile } from "@/stores/session-store"; -import type { LiveFileSnapshot } from "../live-file/model"; - -export interface FilePanePreview { - file: ExplorerFile; - imageAttachment: AttachmentMetadata | null; -} - -export type FilePreviewLifecycleSnapshot = - | { status: "initial" } - | { status: "read_pending"; preview?: FilePanePreview } - | { status: "preparing"; preview?: FilePanePreview } - | { status: "ready"; preview: FilePanePreview } - | { status: "error"; message: string; preview?: FilePanePreview } - | { status: "unsupported" }; - -const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" }; - -/** Converts a completed raw read into the preview resources consumed by FilePane. */ -export async function createFilePanePreview(file: FileReadResult): Promise { - const explorerFile = explorerFileFromReadResult(file); - if (file.kind !== "image") { - return { file: explorerFile, imageAttachment: null }; - } - - const imageAttachment = await persistAttachmentFromBytes({ - id: createPreviewAttachmentId({ - mimeType: file.mime, - path: file.path, - size: file.size, - modifiedAt: file.modifiedAt, - contentLength: file.bytes.byteLength, - }), - bytes: file.bytes, - mimeType: file.mime, - fileName: getFileNameFromPath(file.path), - }); - - return { file: explorerFile, imageAttachment }; -} - -/** Owns conversion after LiveFileModel has produced a raw file snapshot. */ -export class FilePreviewLifecycleModel { - private readonly listeners = new Set<() => void>(); - private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot; - private generation = 0; - private preparedSourceKey: string | null = null; - private targetKey: string | null = null; - - constructor( - private readonly prepare: (file: FileReadResult) => Promise, - ) {} - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot; - - setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void { - const targetChanged = input.targetKey !== this.targetKey; - const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot); - if (targetChanged) { - this.targetKey = input.targetKey; - this.preparedSourceKey = null; - this.generation += 1; - } - - const source = getReadySource(input); - if (source) { - if (source.key === this.preparedSourceKey) { - return; - } - this.preparedSourceKey = source.key; - const generation = ++this.generation; - this.setSnapshot({ - status: "preparing", - ...(retainedPreview ? { preview: retainedPreview } : {}), - }); - void this.prepareSource({ file: source.file, generation, retainedPreview }); - return; - } - - const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot); - if (nextSnapshot.status === "initial" && retainedPreview) { - return; - } - const retainedSnapshot = retainedPreview - ? { ...nextSnapshot, preview: retainedPreview } - : nextSnapshot; - if (sameNonReadyState(this.snapshot, retainedSnapshot)) { - return; - } - this.preparedSourceKey = null; - this.generation += 1; - this.setSnapshot(retainedSnapshot); - } - - private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void { - this.snapshot = snapshot; - for (const listener of this.listeners) listener(); - } - - private async prepareSource(input: { - file: FileReadResult; - generation: number; - retainedPreview?: FilePanePreview; - }): Promise { - try { - const preview = await this.prepare(input.file); - if (input.generation !== this.generation) return; - this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" }); - } catch (error) { - if (input.generation !== this.generation) return; - this.setSnapshot({ - status: "error", - message: errorMessage(error), - ...(input.retainedPreview ? { preview: input.retainedPreview } : {}), - }); - } - } -} - -export function filePreviewFromLifecycle( - snapshot: FilePreviewLifecycleSnapshot, -): FilePanePreview | undefined { - return "preview" in snapshot ? snapshot.preview : undefined; -} - -export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): { - file: ExplorerFile | null; - imageAttachment: AttachmentMetadata | null; -} { - const preview = filePreviewFromLifecycle(snapshot); - return preview ?? { file: null, imageAttachment: null }; -} - -function getReadySource(input: { - targetKey: string | null; - liveFileSnapshot: LiveFileSnapshot; -}): { key: string; file: FileReadResult } | null { - const observation = input.liveFileSnapshot.observation; - if (!input.targetKey || observation?.status !== "ready") { - return null; - } - const { version } = observation; - if (input.targetKey !== `${version.cwd}:${version.path}`) { - return null; - } - return { - key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`, - file: observation.file, - }; -} - -function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot { - if (snapshot.read.status === "error") { - return { status: "error", message: snapshot.read.error }; - } - if (snapshot.read.status === "pending") { - return { status: "read_pending" }; - } - if (snapshot.observation?.status === "error") { - return { status: "error", message: snapshot.observation.error }; - } - if (snapshot.observation?.status === "missing") { - return { status: "error", message: "File not found" }; - } - return initialSnapshot; -} - -function sameNonReadyState( - current: FilePreviewLifecycleSnapshot, - next: FilePreviewLifecycleSnapshot, -): boolean { - if (current.status !== next.status) { - return false; - } - return current.status !== "error" || next.status !== "error" || current.message === next.message; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to prepare preview"; -} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx deleted file mode 100644 index de7c5eeae1..0000000000 --- a/packages/app/src/file-pane/source/view.native.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { FlatList, Text, View, type ListRenderItem } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; -import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; -import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import type { EditorVisualTheme } from "../editor/extensions.web"; -import { selectSourcePresentation } from "./presentation"; - -interface FileSourceViewProps { - content: string; - filename: string; - location: WorkspaceFileLocation; - navigationRevision: number; - size: number; - theme: EditorVisualTheme; - tooLargeMessage: string; -} - -interface SourceLine { - number: number; - tokens: HighlightToken[]; -} - -export function FileSourceView({ - content, - filename, - location, - navigationRevision, - size, - tooLargeMessage, -}: FileSourceViewProps) { - const presentation = selectSourcePresentation({ size, platform: "native" }); - if (presentation === "unsupported") { - return ( - - {tooLargeMessage} - - ); - } - return ( - - ); -} - -function VirtualizedSource({ - content, - filename, - location, - navigationRevision, - presentation, -}: Omit & { - presentation: "highlighted" | "plain"; -}) { - const listRef = useRef>(null); - const lines = useMemo(() => { - if (presentation === "highlighted") - return highlightCode(content, filename).map((tokens, index) => ({ - number: index + 1, - tokens, - })); - return content - .split("\n") - .map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })); - }, [content, filename, presentation]); - useEffect(() => { - if (!location.lineStart) return; - listRef.current?.scrollToIndex({ - index: Math.min(location.lineStart - 1, lines.length - 1), - animated: false, - viewPosition: 0.5, - }); - }, [lines.length, location.lineStart, navigationRevision]); - return ( - - ); -} - -function SourceLineView({ line }: { line: SourceLine }) { - return ( - - {line.number} - - {line.tokens.map((token) => ( - - {token.text} - - ))} - - - ); -} - -function sourceLineKey(line: SourceLine): string { - return String(line.number); -} - -function sourceLineLayout(_data: ArrayLike | null | undefined, index: number) { - return { length: 20, offset: index * 20, index }; -} - -const renderSourceLine: ListRenderItem = ({ item }) => ; - -const styles = StyleSheet.create((theme) => ({ - unsupported: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: theme.spacing[4], - }, - unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, - line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 }, - gutter: { - width: 56, - paddingRight: theme.spacing[3], - textAlign: "right", - color: theme.colors.foregroundMuted, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - }, - text: { - flex: 1, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - lineHeight: theme.fontSize.code * 1.45, - }, -})); diff --git a/packages/app/src/file-panels/associations.ts b/packages/app/src/file-panels/associations.ts new file mode 100644 index 0000000000..6280bf5556 --- /dev/null +++ b/packages/app/src/file-panels/associations.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; + +export interface FilePanelAssociations { + /** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */ + associations: Readonly>; + /** Writes the association for a selector key, or forgets it with null. */ + remember(selectorKey: string, panelId: string | null): void; +} + +/** The one reader and writer of the device's file panel associations. */ +export function useFilePanelAssociations(): FilePanelAssociations { + const { settings, updateSettings } = useAppSettings(); + const remember = useCallback( + (selectorKey: string, panelId: string | null) => { + void updateSettings((current) => ({ + filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId), + })); + }, + [updateSettings], + ); + return { associations: settings.filePanelAssociations, remember }; +} + +function withAssociation( + associations: Readonly>, + selectorKey: string, + panelId: string | null, +): Record { + const next = { ...associations }; + if (panelId === null) delete next[selectorKey]; + else next[selectorKey] = panelId; + return next; +} diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx new file mode 100644 index 0000000000..5407719ab4 --- /dev/null +++ b/packages/app/src/file-panels/builtin.tsx @@ -0,0 +1,104 @@ +import { useWorkspace, useFileUrl, type PluginFilePanelProps } from "@getpaseo/plugin/client"; +import { useCallback, useState } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ZoomableImage } from "@/components/zoomable-viewport/image"; +import { useSessionStore } from "@/stores/session-store"; +import { FileHtmlPreview } from "./html-preview"; +import { FilePanelLoading } from "./loading"; +import { FileMarkdownPreview } from "./markdown-preview"; +import { TextFilePanel, type TextFilePreviewProps } from "./source/panel"; +import type { RenderingFilePanel, SourceFilePanel } from "./catalog"; + +/** Source: the text file panel with no Preview, for text files no other built-in panel claims. */ +function SourcePanel(props: PluginFilePanelProps) { + return ; +} + +/** Markdown: the text file panel with the rendered document as its Preview. */ +function MarkdownPanel(props: PluginFilePanelProps) { + return ; +} + +/** HTML: the text file panel with the sandboxed page as its Preview. */ +function HtmlPanel(props: PluginFilePanelProps) { + return ; +} + +/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */ +function MarkdownPreview({ text, params, workspaceId, host }: TextFilePreviewProps) { + const client = useSessionStore((state) => state.sessions[host.id]?.client ?? null); + const workspaceRoot = useWorkspace(workspaceId, (workspace) => workspace.directory); + const [rendered, setRendered] = useState(false); + const handleLayout = useCallback(() => setRendered(true), []); + return ( + + + + + + + {rendered ? null : } + + ); +} + +/** The page owns its scrolling, so there is no ScrollView around it. */ +function HtmlPreview({ text }: TextFilePreviewProps) { + return ( + + + + ); +} + +function ImagePanel({ file }: PluginFilePanelProps) { + const url = useFileUrl(file); + if (url.status === "loading") return ; + if (url.status === "error") return ; + return ; +} + +function FilePanelError({ message }: { message: string }) { + return ( + + {message} + + ); +} + +const components = { + "builtin:markdown": MarkdownPanel, + "builtin:html": HtmlPanel, + "builtin:image": ImagePanel, + "builtin:source": SourcePanel, +}; + +export function BuiltinFilePanelView({ + panel, + ...props +}: PluginFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { + const Component = components[panel.id]; + return ; +} + +const styles = StyleSheet.create((theme) => ({ + container: { flex: 1, minHeight: 0 }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + errorText: { + color: theme.colors.destructive, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts new file mode 100644 index 0000000000..31635e4a6d --- /dev/null +++ b/packages/app/src/file-panels/catalog.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { filePanelCatalog } from "./catalog"; +import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve"; + +const builtins = filePanelCatalog([]); + +const richMarkdown: FilePanelCandidate = { + id: "plugin:notes/rich", + builtin: false, + files: { extensions: ["md"] }, +}; + +function textFile(path: string): FilePanelFile { + return { path, kind: "text" }; +} + +function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) { + return resolveFilePanel({ file, candidates, associations: {} }).candidates.map( + (candidate) => candidate.id, + ); +} + +function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) { + return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null; +} + +describe("built-in file panels", () => { + it("opens each rendered extension with its panel alone, never with Source beside it", () => { + expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown"]); + expect(candidateIds(textFile("plan.html"))).toEqual(["builtin:html"]); + expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown"); + expect(panelId(textFile("plan.html"))).toBe("builtin:html"); + expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html"); + expect(panelId(textFile("plan.htm"))).toBe("builtin:html"); + }); + + it("opens every other text file with Source, whatever its name", () => { + for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) { + expect(candidateIds(textFile(path))).toEqual(["builtin:source"]); + } + }); + + it("puts plugin panels after the built-in that claims the file, and before Source", () => { + expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([ + "builtin:markdown", + richMarkdown.id, + ]); + const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]); + expect(candidateIds(textFile("notes.txt"), onlyPlugin)).toEqual([ + richMarkdown.id, + "builtin:source", + ]); + expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id); + }); + + it("ignores a remembered Source for a file another built-in claims", () => { + const resolution = resolveFilePanel({ + file: textFile("README.md"), + candidates: builtins, + associations: { "ext:md": "builtin:source" }, + }); + expect(resolution.panel?.id).toBe("builtin:markdown"); + expect(resolution.associatedPanelId).toBeNull(); + }); + + it("has no Source for files that are not text", () => { + expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]); + }); + + it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])( + "registers the image extension %s", + (extension) => { + const resolution = resolveFilePanel({ + file: { path: `/capture.${extension.toUpperCase()}`, kind: "image" }, + candidates: builtins, + associations: { [`ext:${extension}`]: "builtin:markdown" }, + }); + expect(resolution.panel?.id).toBe("builtin:image"); + expect( + resolveFilePanel({ + file: { path: "capture.png", kind: "binary" }, + candidates: builtins, + associations: {}, + }).panel, + ).toBeNull(); + }, + ); +}); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts new file mode 100644 index 0000000000..f77162645e --- /dev/null +++ b/packages/app/src/file-panels/catalog.ts @@ -0,0 +1,46 @@ +import type { FilePanelCandidate } from "./resolve"; + +const renderingPanels = [ + { + id: "builtin:markdown", + builtin: true, + files: { extensions: ["md", "markdown"] }, + fileKind: "text", + }, + { + id: "builtin:html", + builtin: true, + files: { extensions: ["html", "htm"] }, + fileKind: "text", + }, + { + id: "builtin:image", + builtin: true, + files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] }, + fileKind: "image", + }, +] as const satisfies readonly FilePanelCandidate[]; + +/** + * Paseo's editor, the floor for text files: every text file no other built-in claims has it, so a + * text file always opens with some panel. A built-in that claims a file owns its source view. + */ +export const sourceFilePanel = { + id: "builtin:source", + builtin: true, + files: "all", + fileKind: "text", +} as const satisfies FilePanelCandidate; + +export type RenderingFilePanel = (typeof renderingPanels)[number]; +export type SourceFilePanel = typeof sourceFilePanel; + +/** + * Every panel a host offers, in resolver order: the built-ins that render a file, then plugin + * panels, then Source. Source is last so a plugin panel is the default over it. + */ +export function filePanelCatalog( + pluginPanels: readonly Plugin[], +): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] { + return [...renderingPanels, ...pluginPanels, sourceFilePanel]; +} diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts new file mode 100644 index 0000000000..2c2fafe388 --- /dev/null +++ b/packages/app/src/file-panels/choice/hook.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; +import { useFilePanelAssociations } from "../associations"; +import { useFilePanels, type FilePanel } from "../panels"; +import { resolveFilePanel, type FilePanelFileKind } from "../resolve"; +import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model"; +import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry"; + +export type FilePanelChoice = FilePanelChoiceModel; + +/** + * The file tab's panel: resolves it from the catalog, the device associations and this tab's + * choice, and owns the picker and "Open with…" for it. + */ +export function useFilePanelChoice(input: { + serverId: string; + targetKey: string | null; + path: string; + /** The kind the file's stat reports, or null before the stat. */ + fileKind: FilePanelFileKind | null; + /** The tab is on screen. */ + presentable: boolean; + /** Identifies the tab's latest request to show a line, or null when it targets no line. */ + lineNavigation: number | null; +}): { panel: FilePanel | null; choice: FilePanelChoice } { + const { targetKey, path, fileKind, presentable, lineNavigation } = input; + const { serverId, workspaceId, tabId } = usePaneContext(); + const candidates = useFilePanels(input.serverId); + const { associations, remember } = useFilePanelAssociations(); + const [choice] = useState(() => new FilePanelChoiceModel({ remember })); + // From the subscribed snapshot: React Compiler caches reads off the stable model object. + const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey); + const resolution = useMemo( + () => + fileKind + ? resolveFilePanel({ + file: { path, kind: fileKind }, + candidates, + associations, + choice: chosenPanelId, + }) + : null, + [associations, candidates, chosenPanelId, fileKind, path], + ); + + useEffect(() => { + choice.observe({ targetKey, resolution, presentable, lineNavigation }); + }, [choice, lineNavigation, presentable, resolution, targetKey]); + useEffect( + () => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice), + [choice, serverId, tabId, workspaceId], + ); + + return { panel: resolution?.panel ?? null, choice }; +} + +const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot = { + targetKey: null, + chosenPanelId: null, + picker: null, + openWith: null, +}; +const subscribeNothing = () => () => {}; +const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT; + +/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */ +export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null { + const { serverId, workspaceId, tabId } = identity; + const getSnapshot = useCallback( + () => getFilePanelChoice({ serverId, workspaceId, tabId }), + [serverId, tabId, workspaceId], + ); + return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot); +} + +export function useFilePanelChoiceSnapshot( + choice: FilePanelChoice | null, +): FilePanelChoiceSnapshot { + return useSyncExternalStore( + choice?.subscribe ?? subscribeNothing, + choice?.getSnapshot ?? getNoChoiceSnapshot, + choice?.getSnapshot ?? getNoChoiceSnapshot, + ); +} diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts new file mode 100644 index 0000000000..73e98e91a1 --- /dev/null +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { filePanelCatalog, sourceFilePanel } from "../catalog"; +import { resolveFilePanel, type FilePanelCandidate } from "../resolve"; +import { chosenPanelFor, FilePanelChoiceModel } from "./model"; + +const rich: FilePanelCandidate = { + id: "plugin:notes/rich", + builtin: false, + files: { extensions: ["md", "markdown"] }, +}; +const richTypeScript: FilePanelCandidate = { + id: "plugin:ts/outline", + builtin: false, + files: { extensions: ["ts"] }, +}; +const markdown = "builtin:markdown"; +const source = sourceFilePanel.id; + +/** A file tab: resolves against the shared associations and feeds the result to its model. */ +function openTab( + associations: Record, + candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])], + options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {}, +) { + const model = new FilePanelChoiceModel({ + remember(selectorKey, panelId) { + if (panelId === null) delete associations[selectorKey]; + else associations[selectorKey] = panelId; + }, + }); + let targetKey = `/repo:${options.path ?? "notes.md"}`; + let presentable = options.presentable ?? true; + let lineNavigation = options.lineNavigation ?? null; + const resolve = () => + resolveFilePanel({ + file: { path: targetKey.split(":")[1]!, kind: "text" }, + candidates, + associations, + choice: chosenPanelFor(model.getSnapshot(), targetKey), + }); + // Like the hook: observe, then render what the model's choice resolves to. + const render = () => { + model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation }); + return resolve().panel?.id ?? null; + }; + render(); + return { + model, + render, + panel: () => render(), + picker: () => model.getSnapshot().picker, + retarget(path: string) { + targetKey = `/repo:${path}`; + return render(); + }, + setPresentable(next: boolean) { + presentable = next; + return render(); + }, + navigateToLine(revision: number) { + lineNavigation = revision; + return render(); + }, + }; +} + +describe("FilePanelChoiceModel", () => { + it("asks once per tab open over the first candidate, and dismissing keeps it", () => { + const associations = {}; + const tab = openTab(associations); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toMatchObject({ + selectorKey: "ext:md", + selectedPanelId: markdown, + remember: false, + }); + expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([markdown, rich.id]); + tab.model.dismissPicker(); + expect(tab.picker()).toBeNull(); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + expect(associations).toEqual({}); + + expect(openTab(associations).picker()).not.toBeNull(); + }); + + it("opens a file a built-in claims with that built-in alone and nothing to choose", () => { + const tab = openTab({}, [...filePanelCatalog([])]); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + expect(tab.model.getSnapshot().openWith).toBeNull(); + tab.model.openWith(source); + expect(tab.panel()).toBe(markdown); + }); + + it("opens a file only Source claims with Source and nothing to choose", () => { + const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" }); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + expect(tab.model.getSnapshot().openWith).toBeNull(); + }); + + it("remembers the choice when Don't ask me again is checked", () => { + const associations: Record = {}; + const tab = openTab(associations); + tab.model.selectInPicker(rich.id); + tab.model.setRemember(true); + tab.model.confirmPicker(); + expect(tab.panel()).toBe(rich.id); + expect(associations).toEqual({ "ext:md": rich.id }); + + const next = openTab(associations); + expect(next.panel()).toBe(rich.id); + expect(next.picker()).toBeNull(); + }); + + it("uses an unremembered choice for this tab only and asks again on the next open", () => { + const associations = {}; + const tab = openTab(associations); + tab.model.selectInPicker(rich.id); + tab.model.confirmPicker(); + expect(tab.panel()).toBe(rich.id); + expect(associations).toEqual({}); + + const next = openTab(associations); + expect(next.panel()).toBe(markdown); + expect(next.picker()).not.toBeNull(); + }); + + it("does not ask with an association or while the tab is in the background", () => { + expect(openTab({ "ext:md": rich.id }).picker()).toBeNull(); + expect(openTab({ "ext:md": source }).panel()).toBe(markdown); + + const background = openTab({}, undefined, { presentable: false }); + expect(background.picker()).toBeNull(); + background.setPresentable(true); + expect(background.picker()).not.toBeNull(); + }); + + it("opens the built-in panel for a line target without asking, and again on each new one", () => { + const tab = openTab({ "ext:md": rich.id }, undefined, { lineNavigation: 1 }); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + + tab.model.openWith(rich.id); + expect(tab.panel()).toBe(rich.id); + expect(tab.navigateToLine(1)).toBe(rich.id); + expect(tab.navigateToLine(2)).toBe(markdown); + }); + + it("opens Source for a line target in a file only Source and a plugin claim", () => { + const tab = openTab({ "ext:ts": richTypeScript.id }, [...filePanelCatalog([richTypeScript])], { + path: "index.ts", + }); + expect(tab.panel()).toBe(richTypeScript.id); + expect(tab.navigateToLine(1)).toBe(source); + }); + + it("opens the built-in panel for the same line target in a retargeted tab", () => { + const tab = openTab({}, undefined, { lineNavigation: 1 }); + tab.model.openWith(rich.id); + expect(tab.retarget("other.md")).toBe(markdown); + }); + + it("starts a new open when the tab is retargeted", () => { + const tab = openTab({}); + tab.model.selectInPicker(rich.id); + tab.model.confirmPicker(); + expect(tab.retarget("other.md")).toBe(markdown); + expect(tab.picker()).not.toBeNull(); + }); + + it("switches with Open with and toggles Always use without moving the tab", () => { + const associations: Record = {}; + const tab = openTab(associations); + tab.model.dismissPicker(); + expect(tab.model.getSnapshot().openWith).toMatchObject({ + activePanelId: markdown, + remembered: false, + }); + + tab.model.openWith(rich.id); + expect(tab.panel()).toBe(rich.id); + tab.model.toggleAlwaysUse(); + expect(associations).toEqual({ "ext:md": rich.id }); + tab.render(); + expect(tab.model.getSnapshot().openWith?.remembered).toBe(true); + + tab.model.openWith(markdown); + expect(tab.panel()).toBe(markdown); + expect(tab.model.getSnapshot().openWith?.remembered).toBe(false); + tab.model.openWith(rich.id); + tab.render(); + tab.model.toggleAlwaysUse(); + expect(associations).toEqual({}); + expect(tab.panel()).toBe(rich.id); + }); + + it("asks when a plugin panel arrives after the file first rendered", () => { + const candidates: FilePanelCandidate[] = [...filePanelCatalog([])]; + const tab = openTab({}, candidates); + expect(tab.picker()).toBeNull(); + candidates.splice(1, 0, rich); + tab.render(); + expect(tab.picker()).not.toBeNull(); + }); +}); diff --git a/packages/app/src/file-panels/choice/model.ts b/packages/app/src/file-panels/choice/model.ts new file mode 100644 index 0000000000..c28daeca6e --- /dev/null +++ b/packages/app/src/file-panels/choice/model.ts @@ -0,0 +1,202 @@ +import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve"; + +/** What the file tab currently resolves; `resolution` is null until the file has been read. */ +export interface FilePanelChoiceSource { + targetKey: string | null; + resolution: FilePanelResolution | null; + /** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */ + presentable: boolean; + /** Identifies the tab's latest request to show a line, or null when it targets no line. */ + lineNavigation: number | null; +} + +export interface FilePanelPickerState { + selectorKey: string; + candidates: readonly Candidate[]; + selectedPanelId: string; + remember: boolean; +} + +export interface FilePanelOpenWithState { + selectorKey: string; + candidates: readonly Candidate[]; + activePanelId: string; + /** The active panel is the one remembered for `selectorKey`. */ + remembered: boolean; +} + +export interface FilePanelChoiceSnapshot { + targetKey: string | null; + /** The panel picked for this tab open. Resolution prefers it over the association. */ + chosenPanelId: string | null; + picker: FilePanelPickerState | null; + /** Present only when the file has more than one candidate; drives "Open with…". */ + openWith: FilePanelOpenWithState | null; +} + +export interface FilePanelChoiceDeps { + /** Writes (or clears, with null) the device-wide association for a selector key. */ + remember(selectorKey: string, panelId: string | null): void; +} + +/** The tab choice that applies to `targetKey`; a choice never outlives its open. */ +export function chosenPanelFor( + snapshot: FilePanelChoiceSnapshot, + targetKey: string | null, +): string | null { + return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null; +} + +const EMPTY: FilePanelChoiceSnapshot = { + targetKey: null, + chosenPanelId: null, + picker: null, + openWith: null, +}; + +/** + * Which file panel one file tab shows, and whether to ask. + * + * A tab open is one target in one tab: the picker is offered at most once per open, the first + * time the tab is on screen with a plugin panel among several candidates and nothing remembered. + * Built-in panels alone never ask. A line target opens the file's built-in panel, which shows + * lines, the way "Open with…" does. + * Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me + * again" and "Always use" write the association every later open reads. + */ +export class FilePanelChoiceModel { + private readonly listeners = new Set<() => void>(); + private snapshot: FilePanelChoiceSnapshot = EMPTY; + private asked = false; + private openedLineNavigation: number | null = null; + /** A line target arrived before the file resolved; it opens the built-in once it does. */ + private lineTargetPending = false; + + constructor(private readonly deps: FilePanelChoiceDeps) {} + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): FilePanelChoiceSnapshot => this.snapshot; + + observe(source: FilePanelChoiceSource): void { + let current = this.snapshot; + if (source.targetKey !== current.targetKey) { + this.asked = false; + this.openedLineNavigation = null; + this.lineTargetPending = false; + current = { ...EMPTY, targetKey: source.targetKey }; + } + if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) { + this.openedLineNavigation = source.lineNavigation; + this.lineTargetPending = true; + } + if (this.lineTargetPending && source.resolution) { + this.lineTargetPending = false; + const builtin = source.resolution.candidates.find((candidate) => candidate.builtin); + if (builtin) current = { ...current, chosenPanelId: builtin.id, picker: null }; + } + const openWith = stableOpenWith(current.openWith, openWithState(source.resolution)); + let picker = openWith ? current.picker : null; + // Decided once per open, the first time there is something to ask about on screen. An + // association forgotten while the tab stays open is asked about on the next open. + if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) { + this.asked = true; + const remembered = Boolean(source.resolution?.associatedPanelId); + if (!remembered && current.chosenPanelId === null) { + picker = { + selectorKey: openWith.selectorKey, + candidates: openWith.candidates, + selectedPanelId: openWith.activePanelId, + remember: false, + }; + } + } + this.publish({ ...current, openWith, picker }); + } + + selectInPicker(panelId: string): void { + const picker = this.snapshot.picker; + if (!picker) return; + this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } }); + } + + setRemember(remember: boolean): void { + const picker = this.snapshot.picker; + if (!picker) return; + this.publish({ ...this.snapshot, picker: { ...picker, remember } }); + } + + confirmPicker(): void { + const picker = this.snapshot.picker; + if (!picker) return; + if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId); + this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null }); + } + + /** Keeps whatever the tab shows now; the next open asks again. */ + dismissPicker(): void { + if (!this.snapshot.picker) return; + this.publish({ ...this.snapshot, picker: null }); + } + + openWith(panelId: string): void { + this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null }); + } + + /** Remembers the active panel, or forgets it when it already is the remembered one. */ + toggleAlwaysUse(): void { + const openWith = this.snapshot.openWith; + if (!openWith) return; + if (!openWith.remembered) { + this.deps.remember(openWith.selectorKey, openWith.activePanelId); + return; + } + // Forgetting must not move the tab off the panel it is showing. + this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId }); + this.deps.remember(openWith.selectorKey, null); + } + + private publish(next: FilePanelChoiceSnapshot): void { + const current = this.snapshot; + if ( + current.targetKey === next.targetKey && + current.chosenPanelId === next.chosenPanelId && + current.picker === next.picker && + current.openWith === next.openWith + ) { + return; + } + this.snapshot = next; + for (const listener of this.listeners) listener(); + } +} + +function openWithState( + resolution: FilePanelResolution | null, +): FilePanelOpenWithState | null { + if (!resolution?.panel || resolution.candidates.length < 2) return null; + return { + selectorKey: resolution.selectorKey, + candidates: resolution.candidates, + activePanelId: resolution.panel.id, + remembered: resolution.associatedPanelId === resolution.panel.id, + }; +} + +/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */ +function stableOpenWith( + current: FilePanelOpenWithState | null, + next: FilePanelOpenWithState | null, +): FilePanelOpenWithState | null { + if (!current || !next) return next; + const same = + current.selectorKey === next.selectorKey && + current.activePanelId === next.activePanelId && + current.remembered === next.remembered && + current.candidates.length === next.candidates.length && + current.candidates.every((candidate, index) => candidate === next.candidates[index]); + return same ? current : next; +} diff --git a/packages/app/src/file-panels/choice/open-with.tsx b/packages/app/src/file-panels/choice/open-with.tsx new file mode 100644 index 0000000000..33a10aa254 --- /dev/null +++ b/packages/app/src/file-panels/choice/open-with.tsx @@ -0,0 +1,134 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { + MenuItem, + MenuSeparator, + MenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/menu"; +import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; +import { PluginIconView } from "@/plugins/icons"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook"; +import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation"; + +const OPEN_WITH_PAGE_ID = "filePanelOpenWith"; +const MENU_ICON_SIZE = 16; + +const ThemedPanelIcon = withUnistyles(PluginIconView); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const NO_PAGES: readonly MenuPageDefinition[] = []; + +export interface FilePanelOpenWithMenu { + /** The "Open with…" row, or null when the file has one panel or none. */ + trigger: ReactElement | null; + /** The page the row opens; pass to the menu surface. */ + pages: readonly MenuPageDefinition[]; +} + +/** "Open with…" for a file tab's context menu, found by the tab's identity. */ +export function useFilePanelOpenWithMenu( + identity: PanelInstanceIdentity, + testIDBase: string, +): FilePanelOpenWithMenu { + return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase); +} + +function useOpenWithMenu( + choice: FilePanelChoice | null, + testIDBase: string, +): FilePanelOpenWithMenu { + const { t } = useTranslation(); + const { openWith } = useFilePanelChoiceSnapshot(choice); + return useMemo(() => { + if (!choice || !openWith) return { trigger: null, pages: NO_PAGES }; + const title = t("panels.file.filePanels.openWith"); + return { + trigger: ( + + {title} + + ), + pages: [ + { + id: OPEN_WITH_PAGE_ID, + title, + content: ( + <> + {openWith.candidates.map((panel) => ( + + ))} + + + + ), + }, + ], + }; + }, [choice, openWith, t, testIDBase]); +} + +function OpenWithItem({ + choice, + panel, + label, + selected, + testID, +}: { + choice: FilePanelChoice; + panel: FilePanel; + label: string; + selected: boolean; + testID: string; +}) { + const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]); + const leading = useMemo( + () => ( + + ), + [panel], + ); + return ( + + {label} + + ); +} + +function AlwaysUseItem({ + choice, + label, + selected, + testID, +}: { + choice: FilePanelChoice; + label: string; + selected: boolean; + testID: string; +}) { + const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]); + return ( + + {label} + + ); +} diff --git a/packages/app/src/file-panels/choice/picker.tsx b/packages/app/src/file-panels/choice/picker.tsx new file mode 100644 index 0000000000..61d5aba8b6 --- /dev/null +++ b/packages/app/src/file-panels/choice/picker.tsx @@ -0,0 +1,233 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check } from "lucide-react-native"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { PluginIconView } from "@/plugins/icons"; +import { settingsStyles } from "@/styles/settings"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import type { FilePanelPickerState } from "./model"; +import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook"; +import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation"; + +const ThemedCheck = withUnistyles(Check); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The first-open question for a file with several panels, over the panel already showing. */ +export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) { + const { t } = useTranslation(); + const { picker } = useFilePanelChoiceSnapshot(choice); + // The sheet animates out after the model has closed the picker; keep drawing what it asked. + const [shown, setShown] = useState(picker); + if (picker && picker !== shown) setShown(picker); + const content = picker ?? shown; + + const dismiss = useCallback(() => choice.dismissPicker(), [choice]); + const confirm = useCallback(() => choice.confirmPicker(), [choice]); + const toggleRemember = useCallback( + () => choice.setRemember(!choice.getSnapshot().picker?.remember), + [choice], + ); + const header = useMemo( + () => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }), + [content, t], + ); + const footer = useMemo( + () => ( + + ), + [confirm, t], + ); + + return ( + + {content ? ( + + ) : null} + + ); +} + +const PICKER_SNAP_POINTS = ["60%", "90%"]; + +function PickerBody({ + choice, + picker, + onToggleRemember, +}: { + choice: FilePanelChoice; + picker: FilePanelPickerState; + onToggleRemember: () => void; +}) { + const { t } = useTranslation(); + const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]); + return ( + <> + + {picker.candidates.map((panel, index) => ( + 0} + onSelect={select} + /> + ))} + + + + ); +} + +function PanelRadioRow({ + panel, + label, + selected, + withBorder, + onSelect, +}: { + panel: FilePanel; + label: string; + selected: boolean; + withBorder: boolean; + onSelect: (panelId: string) => void; +}) { + const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]); + const accessibilityState = useMemo(() => ({ checked: selected }), [selected]); + const rowStyle = useMemo( + () => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow], + [withBorder], + ); + return ( + + + {selected ? : null} + + + + {label} + + + ); +} + +function CheckboxRow({ + label, + checked, + onToggle, +}: { + label: string; + checked: boolean; + onToggle: () => void; +}) { + const accessibilityState = useMemo(() => ({ checked }), [checked]); + return ( + + + {checked ? : null} + + {label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + footerButton: { + flex: 1, + }, + optionRow: { + justifyContent: "flex-start", + gap: theme.spacing[3], + }, + radio: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + radioSelected: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + radioDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + }, + checkboxRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + }, + checkbox: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + checkboxChecked: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.accent, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, +})); diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts new file mode 100644 index 0000000000..34ecc7265b --- /dev/null +++ b/packages/app/src/file-panels/choice/presentation.ts @@ -0,0 +1,52 @@ +import type { TFunction } from "i18next"; +import type { PluginIcon } from "@getpaseo/plugin/client"; +import type { FilePanel } from "../panels"; +import { parseFilePanelSelectorKey } from "../resolve"; + +const BUILTIN_PRESENTATION = { + "builtin:markdown": { title: "markdown", icon: "FileText" }, + "builtin:html": { title: "html", icon: "FileCode" }, + "builtin:image": { title: "image", icon: "Image" }, + "builtin:source": { title: "source", icon: "Code" }, +} as const; + +export function filePanelIcon(panel: FilePanel): PluginIcon { + return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon; +} + +/** "Markdown", "Rich Markdown": the panel's title. */ +export function filePanelTitle(t: TFunction, panel: FilePanel): string { + return panel.builtin + ? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`) + : panel.plugin.i18n.resolve(panel.contribution.title, panel.plugin.locale); +} + +/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */ +export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string { + return t("panels.file.filePanels.option", { + title: filePanelTitle(t, panel), + owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id, + }); +} + +/** ".md" for an extension key, "Dockerfile" for a filename key. */ +export function filePanelSelectorLabel(selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" ? selector.value : `.${selector.value}`; +} + +/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */ +export function filePanelPickerTitle(t: TFunction, selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" + ? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value }) + : t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value }); +} + +/** "Always use for .md", "Always use for Dockerfile". */ +export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" + ? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value }) + : t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value }); +} diff --git a/packages/app/src/file-panels/choice/registry.ts b/packages/app/src/file-panels/choice/registry.ts new file mode 100644 index 0000000000..43a73984b6 --- /dev/null +++ b/packages/app/src/file-panels/choice/registry.ts @@ -0,0 +1,42 @@ +import { + buildPanelInstanceKey, + type PanelInstanceIdentity, +} from "@/panels/panel-instance-attributes"; +import type { FilePanel } from "../panels"; +import type { FilePanelChoiceModel } from "./model"; + +/** + * The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu) + * reads and drives the same choice the pane renders. + */ +const models = new Map>(); +const listeners = new Set<() => void>(); + +export function registerFilePanelChoice( + identity: PanelInstanceIdentity, + model: FilePanelChoiceModel, +): () => void { + const key = buildPanelInstanceKey(identity); + models.set(key, model); + notify(); + return () => { + if (models.get(key) !== model) return; + models.delete(key); + notify(); + }; +} + +export function getFilePanelChoice( + identity: PanelInstanceIdentity, +): FilePanelChoiceModel | null { + return models.get(buildPanelInstanceKey(identity)) ?? null; +} + +export function subscribeFilePanelChoices(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify(): void { + for (const listener of listeners) listener(); +} diff --git a/packages/app/src/file-panels/document-frame/index.tsx b/packages/app/src/file-panels/document-frame/index.tsx new file mode 100644 index 0000000000..c931ef5f08 --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.tsx @@ -0,0 +1,98 @@ +import { useCallback, useMemo, useRef } from "react"; +import { Platform, Text, View } from "react-native"; +import { WebView } from "react-native-webview"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; + +const frameStyle = { flex: 1, backgroundColor: "white" }; +const originWhitelist = ["*"]; + +/** What Android's WebView shows inline. It has no viewer for PDF or other documents. */ +const ANDROID_INLINE_EXTENSIONS = new Set([ + "html", + "htm", + "xhtml", + "xml", + "svg", + "txt", + "json", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "avif", +]); + +export function DocumentFrame({ url, testID }: { url: string; testID?: string }) { + const source = useMemo(() => ({ uri: url }), [url]); + const loaded = useRef(null); + const allowDocument = useCallback( + (request: { url: string }) => { + if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false; + loaded.current = url; + return true; + }, + [url], + ); + if (!url.startsWith("file:")) return null; + // The host names a file URL after the file, so its extension is the document's type. + const extension = urlExtension(url); + if (Platform.OS === "android" && !ANDROID_INLINE_EXTENSIONS.has(extension)) { + return ; + } + return ( + + ); +} + +function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) { + const { t } = useTranslation(); + return ( + + + {t("panels.file.documentUnsupported", { type: extension.toUpperCase() })} + + + ); +} + +function urlExtension(url: string): string { + const path = url.split(/[?#]/)[0]; + const name = path.slice(path.lastIndexOf("/") + 1); + const dot = name.lastIndexOf("."); + return dot < 0 ? "" : name.slice(dot + 1).toLowerCase(); +} + +const styles = StyleSheet.create((theme) => ({ + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + backgroundColor: theme.colors.surface0, + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-panels/document-frame/index.web.tsx b/packages/app/src/file-panels/document-frame/index.web.tsx new file mode 100644 index 0000000000..a4cf9ac83d --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.web.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { documentFrameSandbox } from "./sandbox"; + +const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const; + +interface ResolvedDocument { + url: string; + sandbox: string | null; +} + +export function DocumentFrame({ url, testID }: { url: string; testID?: string }) { + const [document, setDocument] = useState(null); + + useEffect(() => { + if (!url.startsWith("blob:")) return; + const lifetime = new AbortController(); + void fetch(url, { signal: lifetime.signal }) + .then(async (response) => { + const sandbox = documentFrameSandbox(response.headers.get("content-type")); + await response.body?.cancel(); + if (!lifetime.signal.aborted) setDocument({ url, sandbox }); + return undefined; + }) + .catch(() => { + if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" }); + }); + return () => lifetime.abort(); + }, [url]); + + if (!url.startsWith("blob:") || document?.url !== url) return null; + return ( +