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/workflows/ci.yml b/.github/workflows/ci.yml index e632cb4b96..285ff8e072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: [main] - # Every base branch, so stacked PRs and PRs into `next` get the same checks. + # Every base branch, so stacked PRs and PRs into any base get the same checks. pull_request: merge_group: workflow_dispatch: @@ -133,11 +133,12 @@ jobs: 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 @@ -280,10 +281,10 @@ jobs: # 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 @@ -395,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 @@ -417,19 +418,11 @@ jobs: - name: Run app unit tests run: npm run test --workspace=@getpaseo/app - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Run Android module tests - run: npm run test:android --workspace=@getpaseo/app - playwright-1: 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/8" PLAYWRIGHT_ARTIFACT: "1" @@ -485,7 +478,7 @@ jobs: 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/8" PLAYWRIGHT_ARTIFACT: "2" @@ -495,7 +488,7 @@ jobs: 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/8" PLAYWRIGHT_ARTIFACT: "3" @@ -505,7 +498,7 @@ jobs: 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/8" PLAYWRIGHT_ARTIFACT: "4" @@ -515,7 +508,7 @@ jobs: name: playwright (shard 5/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: "5/8" PLAYWRIGHT_ARTIFACT: "5" @@ -525,7 +518,7 @@ jobs: name: playwright (shard 6/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: "6/8" PLAYWRIGHT_ARTIFACT: "6" @@ -535,7 +528,7 @@ jobs: name: playwright (shard 7/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: "7/8" PLAYWRIGHT_ARTIFACT: "7" @@ -545,7 +538,7 @@ jobs: name: playwright (shard 8/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: "8/8" PLAYWRIGHT_ARTIFACT: "8" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d81c036e62..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: 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 f5078649c9..ae335c0e48 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,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", @@ -236,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", @@ -258,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", @@ -373,14 +539,10 @@ "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/app/pair-scan.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-host-method-modal.tsx", - "packages/app/src/components/add-host-modal.tsx", "packages/app/src/components/add-project-flow.tsx", - "packages/app/src/components/add-remote-ssh-host-modal.tsx", "packages/app/src/components/agent-list.tsx", "packages/app/src/components/app-diagnostic-sheet.tsx", "packages/app/src/components/attachment-lightbox.tsx", diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d1c76900..fee6f2395e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,175 @@ # Changelog +## 0.15.3 - 2026-11-06 + +### Fixed + +- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp)) + +## 0.15.2 - 2026-11-05 + +### Added + +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +### Improved + +- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +## 0.15.1 - 2026-11-05 + +### Fixed + +- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810)) + +## 0.15.0 - 2026-11-03 + +Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery. + +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)) +- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) 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 chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568)) +- 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 +- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt +- 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 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 421da176dd..5a0265ae15 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,4 +1,174 @@ -# 更新日志 +# Changelog + +## 0.15.3 - 2026-11-06 + +### 修复 + +- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp)) + +## 0.15.2 - 2026-11-05 + +### 新增 + +- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +### 改进 + +- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +## 0.15.1 - 2026-11-05 + +### 修复 + +- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810)) + +## 0.15.0 - 2026-11-03 + +daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 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), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@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)) +- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568)) +- 修复了删除 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 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词 +- 降级到 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 diff --git a/CLAUDE.md b/CLAUDE.md index 3f1731597c..1496bd407b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | | [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | | [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/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits | | [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 | @@ -52,7 +53,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [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/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost | | [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 | | [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | @@ -66,6 +69,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 | @@ -116,10 +120,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir ## 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/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 719b0b5468..26c9df8823 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -12,6 +12,27 @@ 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 + +Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or +Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon +reopens the saved provider conversation and settings, then sends `Resume working.`. Customize +the prompt with `agents.resumePrompt` or the same settings page. +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 @@ -115,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. +Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`. +This is informational ancestry for future workspace grouping. It does not change placement, +visibility, status, or archive behavior. Archiving a parent workspace preserves its children's +records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is +never inferred from directories, projects, or Git branches. + 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. @@ -139,8 +166,10 @@ 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. Recovery never resumes -child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop +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 @@ -160,23 +189,42 @@ Some providers can create their own child sessions inside one provider runtime. The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle. -## Background workspaces +## Background visibility -`workspace.background` controls discovery only. Background workspaces contain ordinary durable -agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery -keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot -change it. New workspaces default to false, inherit the caller workspace's value when an agent -creates one without an override, and accept explicit true or false. +An agent is effectively background when it or its workspace is background: +`agent.background || workspace.background`. Background controls discovery only. Background agents +and the agents in background workspaces are ordinary durable agents: provider history, plugin +hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their +normal behavior. Both flags are fixed at creation. -The daemon derives agent discovery from workspace ownership. Default workspace and agent lists, -including History requests, exclude background work; `filter.includeBackground` includes it. -Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only -its sidebar with **Show background**, so direct navigation and restored tabs remain available. +- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does + not watch, such as a plugin's classifier in the user's open workspace. A background agent never + raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays + out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still + works. +- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show + background** is on. +- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the + caller's own `background`. A new workspace takes the caller's effective background. A request + that creates its workspace applies `background` to both the agent and the workspace. Explicit + true or false always wins. + +`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists. +Default workspace and agent lists, including History requests, exclude background work; +`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app +replicates inclusively and filters at each surface. + +Sources without the `background_agents` hello capability get workspace-only visibility on +inclusive reads and subscriptions: agents in background workspaces, but no agent-level background +agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier +run would otherwise open a tab each turn. That visibility has its own directory sync generation. Sequenced directories keep complete canonical collections. A filtered read must never replace those collections with its subset. Inclusive and default observations have distinct cursor scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache. -Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`. +Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`. +They gate a background agent placed in an existing workspace or under a caller on +`features.backgroundAgents`. ### Private daemon helpers @@ -211,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. +Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up. +The daemon runs one archive lane per workspace, shared by every connection +(`packages/server/src/server/workspace-archive-operations.ts`): + +| A request arrives while | Result | +| ------------------------ | --------------------------------------------------------- | +| an archive runs: archive | joins the running archive | +| an archive runs: undo | aborts the archive, waits for it to settle, then restores | +| an undo runs: archive | waits for the undo, then archives | +| an undo runs: undo | joins the running undo | + +A request takes its place in the lane before its first `await`. Arrival order decides, so an undo +sent right behind its archive still cancels it. + +An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only +the agents that archive itself archived. Deleting the worktree directory is the point of no +return: an undo after it recreates the worktree through workspace recovery. The client shows or +hides the row by the latest local intent, so a late remove or upsert from a superseded request +does not flip it back. Tabs and layout are purged only once the undo window closes with the +archive committed. + History navigation opens the selected agent without changing either archive state. Workspace **Restore** recovers only the workspace; the selected archived agent stays open with its callout. The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and @@ -263,17 +332,27 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. -A finished workspace can be marked unread after it has been reviewed. The daemon restores -`finished` attention on its newest eligible workspace-root agent without sending a new completion -notification. Opening the workspace clears that attention through the normal focus flow. +A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores +attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is +`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores +its selected tab; attention on another agent remains unread until that agent is focused or the +workspace is explicitly marked read. -Attention is set by the agent finishing or failing and cleared by the client's -`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is +Attention is set by the agent finishing or failing and acknowledged by deliberate focus or +interaction with its chat, including repeat clicks in an already focused pane. A completion +arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is neither, so resuming carries the stored attention and the stored last-activity time through untouched. Forging either makes a background resume look like the user read a workspace and like the agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted `updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start. +The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error` +until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the +green one. For the same reason every new failure raises `error` attention, including a failed reload +that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed +agent would show the green dot or none. Subagents track marks are the exception: they follow the +child's lifecycle, because the track is where a failed child stays visible. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. @@ -288,7 +367,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. 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. +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/agent-stream-disclosure.md b/docs/agent-stream-disclosure.md new file mode 100644 index 0000000000..9cde8a44ff --- /dev/null +++ b/docs/agent-stream-disclosure.md @@ -0,0 +1,163 @@ +# Agent stream disclosures + +Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group, +a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the +first painted frame. No animation. + +## The contract + +A disclosure component knows nothing about the list. It wraps its state change: + +```tsx +const hold = useDisclosureHold(); +const handleToggle = () => hold(() => setExpanded((value) => !value)); +``` + +`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's +`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row, +`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place. + +Following output: + +- A hold always detaches the bottom anchor controller (`holdForDisclosure`). +- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll + owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves + the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes + following. +- Scroll events during the hold never re-stick: the correction is a programmatic scroll that + would otherwise read as "back at the bottom". + +The trigger can only stay put when the list has scroll room for the change. When the loaded +content fits the viewport, or a collapse at the end would need an offset past the end, the list +ends at its end and the trigger moves by the difference. + +## Web + +`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores +the row's top after layout, before paint (`strategy-web.tsx`). + +## Native + +On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do +not hold. Summary lines and todo cards expand inline. + +The timeline is a LegendList in forward order: one `data` array of history rows followed by the +live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size +change in the commit that makes it, so a hold needs no anchor neighbour, native child index or +render window stretch. The inverted FlatList it replaced needed all three, and three commits per +toggle; its applied commit took up to 3.5 s of JS on a long turn. + +### Who owns the offset + +One owner keeps the scroll offset at any moment: + +| State | Owner | +| ---------------------- | ----------------------------------------------------------------------------------------- | +| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. | +| Following the end | The end pin (`native-end-pin.tsx`). | +| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. | +| User scroll | The finger. The controller detaches at drag start. | +| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. | +| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. | + +The end pin is a zero-height view at the bottom of the content and the content's first native +child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The +native correction runs on the UI thread around each mount, so a row that grows or lands at the end +scrolls in the frame it lays out. The controller scrolls only for a viewport resize (the composer +growing or shrinking) and an explicit jump, both through the native `scrollToEnd`. + +Do not use LegendList's `maintainScrollAtEnd` instead. It scrolls from JS after the mount to the end +its JS sizes predict: growth paints below the viewport for a frame, and the scroll lands short of +the native end, which the controller reads as the user scrolling away. + +The keyboard never changes the list's size, padding or offset. `KeyboardTranslateView` moves the list +and the composer together on the UI thread, and the composer viewport clips the top of the list +while the keyboard is open. Do not add a keyboard-sized inset to keep that content reachable: it can +only commit after the motion, and while detached or holding nothing corrects for it, so every row +jumps by the keyboard height after the keyboard settles. + +### The hold + +`holdRow(rowId, apply)` runs in the toggle's event: + +1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and + LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row + (`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible + one down, and a size change with no data change keeps the first row in view whatever + `shouldRestorePosition` says. A row cut off at the top would then let the row below it hold + instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off + at the top as in view and applies `shouldRestorePosition` to size changes too. +2. Run `apply`. +3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so + LegendList's container does not re-render and would learn the new height from `onLayout` a task + later, after the change painted. The trigger puts the measurement and the corrected positions + in the toggle's own commit. + +The hold ends two frames after the last item size change, since the list measures the toggled row +and any rows it inserted over several frames (`native-disclosure-hold.ts`). The controller then +re-sticks only if the list is within 1 px of its end, the same distance web resumes from. + +### User scroll + +The controller detaches at `onScrollBeginDrag`, so lines that stream in during a drag grow below +the rows under the finger. The detach reaches native a JS round trip after the drag begins, so for +those frames the pin is still mounted. The scroll view subclasses skip the pin's correction while +the user drags (see [scroll view corrections](#scroll-view-corrections)). They skip only the pin's: +in the detached state LegendList uses the same native correction, and a history page that lands +mid-drag needs it. They key on the drag, not the touch, so a tap or long press while following +keeps following. + +Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts +or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that +land during the bounce must keep scrolling in. A release anywhere short of the end stays detached +with the scroll-to-bottom pill, so the rows the user let go of never move. + +### Draw distance + +`drawDistance` is 2000 px on each side of the viewport. A fling over rows that were never measured +blanks the viewport until LegendList renders and measures them. Twelve flings over a chat with a +15,000 px turn expanded, Android emulator, dev build: + +| List | Blank viewport | Janky frames | p50 / p90 / p99 | +| ----------------------------- | -------------------------- | ------------ | ---------------- | +| Inverted FlatList | 1.3 to 1.7 s over 5 flings | 10.8% | 27 / 48 / 109 ms | +| LegendList, drawDistance 500 | 1.0 to 1.5 s per fling | | | +| LegendList, drawDistance 2000 | none | 1.7% | 24 / 32 / 48 ms | + +With every turn collapsed, flinging from a cold open into history did not blank at any value from +500 to 2000, and jank followed host load (19 to 40 on 16 cores) rather than draw distance. Measure +1000 and 1500 on the expanded chat, on a host under load 10, before lowering it. + +### LegendList patch + +`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists +them by purpose, and +[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream. +Drop a hunk when the installed LegendList ships its fix. + +### Scroll view corrections + +`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass: +Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`. +`VisibleContentPositionClampTest` covers Android; iOS has no native test harness. + +**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set +applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during +the mount, and React Native then adds the anchor's movement to the clamped offset +([RN #58578](https://github.com/react/react-native/issues/58578), fix in +[PR #58695](https://github.com/react/react-native/pull/58695)). A turn that folds while the list +follows its end lands above the end. Android defers the clamp to the pre-draw pass, and iOS restores +the pre-mount offset for the adjustment and clamps after it. + +**Removed anchor (iOS).** A hold removes the end pin in the same mount that opens the tapped +disclosure, and React Native 0.86 on iOS moves the offset by the removed view's frame. The iOS +subclass keeps the offset. Android keeps a removed view's last frame, so it keeps the offset already. +React Native 0.88 skips the adjustment for a removed anchor +([PR #57294](https://github.com/react/react-native/pull/57294)). + +**End pin during a drag.** Both subclasses recognize the pin by its native ID, `paseo-end-pin`, +and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React +Native counterpart; it stays while the timeline uses the pin. + +Remove the content clamp and removed-anchor overrides once the installed React Native includes +their fixes. diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index ec506ee99b..4c43718215 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -34,10 +34,11 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. -- **A history row re-renders only when its item or layout item identity changes.** The inverted - FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a - memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of - JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed, +- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice. +- **A history row re-renders only when its item or layout item identity changes.** Every + coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every + mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a + phone, 100–250 ms of JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed, `useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state, or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history through that hook; the web viewport once skipped it and history hosts of a live tool group went diff --git a/docs/android.md b/docs/android.md index 5a59eedaa4..8b6201ae91 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,6 +61,18 @@ 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 @@ -98,7 +110,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo `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` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain. +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 @@ -162,14 +174,18 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. -## Inverted timeline selection +## Scroll view corrections -Android focus and selection visibility requests must not reposition inverted timelines. The -`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for -child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React -Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive -`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll -commands still work; non-inverted scroll views keep Android's default behavior. +`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which +keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the +chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) +lists the corrections and the React Native issues they work around. Its instrumentation test, +`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager: + +```bash +cd packages/app/android +./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` Register this package before React Native's core package through its Expo config plugin. Normal Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does @@ -215,7 +231,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/ @@ -223,6 +240,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 29f9c7f817..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,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 @@ -490,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 index 20c86c1295..7e8b67ee62 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi 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. +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 diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..ef0281ec7f 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" @@ -747,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. +Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value. + ### Gotcha: `extends: "claude"` with third-party endpoints When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors. diff --git a/docs/data-model.md b/docs/data-model.md index dc65096b66..38c13b63df 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr restore, branch auto-name, and descriptor flows consume those persisted facts rather than rediscovering ownership from a directory that may already be gone. Reconciliation may refresh mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. +An explicit base selection is stored in the checkout's Git-directory Paseo metadata and +mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps +recovery possible after the checkout metadata is deleted. A newly opened workspace inherits +the checkout's selected base; separate worktrees keep separate bases. Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing `worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`. @@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | | `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | | `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | -| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. | +| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | | `createdAt` | `string` (ISO 8601) | | diff --git a/docs/design.md b/docs/design.md index fe60cdc53b..2d769e4f2d 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 `
diff --git a/packages/app/src/agent-skills/index.tsx b/packages/app/src/agent-skills/index.tsx index 1241192b50..9be1813dfe 100644 --- a/packages/app/src/agent-skills/index.tsx +++ b/packages/app/src/agent-skills/index.tsx @@ -3,29 +3,27 @@ import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { useFocusEffect } from "expo-router/react-navigation"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native"; +import { Settings2 } from "lucide-react-native"; import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages"; import type { TFunction } from "i18next"; +import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings"; import { Button } from "@/components/ui/button"; -import { SettingsSection } from "@/components/settings/headings/settings-section"; +import { Switch } from "@/components/ui/switch"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { openExternalUrl } from "@/utils/open-external-url"; import { SkillSelectionSheet } from "./selection-sheet"; -import { useAgentSkills } from "./use-agent-skills"; +import { useAgentSkills, type AgentSkillsController } from "./use-agent-skills"; + +export { useAgentSkills, type AgentSkillsController } from "./use-agent-skills"; const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2, }; -const ThemedBlocks = withUnistyles(Blocks); -const ThemedCheck = withUnistyles(Check); const ThemedSettings = withUnistyles(Settings2); -const ThemedArrowUpRight = withUnistyles(ArrowUpRight); const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; -const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string { @@ -35,91 +33,50 @@ function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): .join("\n"); } +/** Whether the host has Paseo's skills installed in any form, current or not. */ +export function areSkillsInstalled(status: AgentSkillsStatus | null): boolean { + return status !== null && status.state !== "not-installed"; +} + +/** Whether the saved selection names any skill there is to install. */ +export function hasSelectedSkills(status: AgentSkillsStatus | null): boolean { + if (!status) return false; + if (status.selection.mode === "all") return status.available.length > 0; + return status.selection.skills.some((name) => status.available.includes(name)); +} + export function AgentSkillsSection({ serverId }: { serverId: string }) { const { t } = useTranslation(); const skills = useAgentSkills(serverId); - const refreshSkills = skills.refresh; - const skillsConnected = skills.connected; - const skillsSupported = skills.supported; - const [isChoosing, setIsChoosing] = useState(false); + const { refresh, connected, supported, reconcile, uninstall } = skills; useFocusEffect( useCallback(() => { - if (skillsConnected && skillsSupported) void refreshSkills(); + if (connected && supported) void refresh(); return undefined; - }, [refreshSkills, skillsConnected, skillsSupported]), + }, [refresh, connected, supported]), ); - const maintenanceOps = useMemo( - () => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [], - [skills.status?.ops], + const handleValueChange = useCallback( + (next: boolean) => { + void (next ? reconcile() : uninstall()); + }, + [reconcile, uninstall], ); - const state = - skills.status?.state === "drift" && maintenanceOps.length === 0 - ? "up-to-date" - : (skills.status?.state ?? null); - const hasSelected = - (skills.status?.selection.mode === "all" && skills.status.available.length > 0) || - (skills.status?.selection.mode === "custom" && - skills.status.selection.skills.some((name) => skills.status?.available.includes(name))); - - const handleReconcile = useCallback(async () => { - if (skills.isWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.host.skills.updateTitle"), - message: - maintenanceOps.length > 0 - ? formatUpdateMessage(maintenanceOps, t) - : t("settings.host.skills.updateFallback"), - confirmLabel: t("settings.host.skills.actions.update"), - }); - if (confirmed) await skills.reconcile(); - }, [maintenanceOps, skills, t]); - const handleUninstall = useCallback(async () => { - if (skills.isWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.host.skills.uninstallTitle"), - message: t("settings.host.skills.uninstallMessage"), - confirmLabel: t("settings.host.skills.actions.uninstall"), - destructive: true, - }); - if (confirmed) await skills.uninstall(); - }, [skills, t]); - const chooseIcon = useMemo( - () => , - [], - ); - const handleOpen = useCallback(() => setIsChoosing(true), []); - const handleClose = useCallback(() => setIsChoosing(false), []); - const handleOpenDocs = useCallback(() => { - void openExternalUrl(SKILLS_DOCS_URL); - }, []); - const docsIcon = useMemo( - () => , - [], - ); - const trailing = useMemo( - () => ( - - ), - [docsIcon, handleOpenDocs, t], + const docsLink = useMemo( + () => ({ + href: SKILLS_DOCS_URL, + label: t("settings.host.skills.docs"), + accessibilityLabel: t("settings.host.skills.openDocs"), + }), + [t], ); - if (!skills.connected || !skills.supported) { + if (!connected || !supported) { return ( - + - {skills.connected + {connected ? t("settings.host.skills.unsupported") : t("settings.host.skills.unavailable")} @@ -130,109 +87,112 @@ export function AgentSkillsSection({ serverId }: { serverId: string }) { } return ( - - - - - - - {t("settings.host.skills.title")} - - - {state === "drift" - ? t("settings.host.skills.updateAvailable") - : t("settings.host.skills.description")} - - - - - - ); - } - if (state === "drift") { - return ( - - - - - ); - } + const { status, isWorking, reconcile, saveSelection } = skills; + const [isChoosing, setIsChoosing] = useState(false); + // Deleting a deselected skill is a choice made in the picker, not an update to offer here. + const maintenanceOps = useMemo( + () => status?.ops.filter((op) => op.kind !== "delete") ?? [], + [status?.ops], + ); + const needsUpdate = status?.state === "drift" && maintenanceOps.length > 0; + + const handleUpdate = useCallback(async () => { + if (isWorking) return; + const confirmed = await confirmDialog({ + title: t("settings.host.skills.updateTitle"), + message: formatUpdateMessage(maintenanceOps, t), + confirmLabel: t("settings.host.skills.actions.update"), + }); + if (confirmed) await reconcile(); + }, [isWorking, maintenanceOps, reconcile, t]); + const handleOpen = useCallback(() => setIsChoosing(true), []); + const handleClose = useCallback(() => setIsChoosing(false), []); + const chooseIcon = useMemo( + () => , + [], + ); + return ( - + <> + + + {needsUpdate ? ( + + ) : null} + 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 1b4b8d18e8..876c615577 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,7 +24,9 @@ function viewport(root: HTMLElement, capture: Capture) { const handle: StreamViewportHandle = { scrollToBottom() {}, prepareForViewportChange() {}, - holdReadingPosition() {}, + holdRow(_rowId, apply) { + apply(); + }, 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 890f6eaae7..1ca917f0b3 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,7 +265,7 @@ describe("useChatOutline", () => { const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), - holdReadingPosition: vi.fn(), + holdRow: vi.fn(), scrollToMessage, }; const viewportRef = { current: viewport }; @@ -317,7 +317,7 @@ describe("useChatOutline", () => { current: { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), - holdReadingPosition: vi.fn(), + holdRow: vi.fn(), scrollToMessage, }, }; @@ -373,7 +373,7 @@ describe("useChatOutline", () => { const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), - holdReadingPosition: vi.fn(), + holdRow: vi.fn(), scrollToMessage, }; const livePrompt = { diff --git a/packages/app/src/agent-stream/disclosure.tsx b/packages/app/src/agent-stream/disclosure.tsx new file mode 100644 index 0000000000..b35e67e567 --- /dev/null +++ b/packages/app/src/agent-stream/disclosure.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react"; +import type { StreamViewportHandle } from "./strategy"; + +/** + * Runs a disclosure's state change. Inside a timeline row, the row that owns the + * trigger keeps its screen position through the change; elsewhere `apply` runs as is. + */ +export type DisclosureHold = (apply: () => void) => void; + +const applyInPlace: DisclosureHold = (apply) => apply(); + +const DisclosureHoldContext = createContext(applyInPlace); + +export function DisclosureRow({ + rowId, + viewportRef, + children, +}: { + rowId: string; + viewportRef: RefObject; + children: ReactNode; +}) { + const hold = useMemo( + () => (apply) => { + const viewport = viewportRef.current; + if (viewport) { + viewport.holdRow(rowId, apply); + } else { + apply(); + } + }, + [rowId, viewportRef], + ); + return {children}; +} + +/** Call with the toggle's state change: `hold(() => setExpanded(!expanded))`. */ +export function useDisclosureHold(): DisclosureHold { + return useContext(DisclosureHoldContext); +} diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index 5fa3535bce..e3e28ab0bc 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -3,32 +3,13 @@ import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { layoutStream } from "./layout"; import type { StreamStrategy } from "./strategy"; +import { resolveStreamRenderStrategy } from "./strategy-resolver"; function strategyFor(platform: "web" | "native"): StreamStrategy { - const inverted = platform === "native"; - const neighborStep = inverted ? 1 : -1; - const getHistoryBoundaryIndex = (items: StreamItem[]) => { - if (items.length === 0) return null; - return inverted ? 0 : items.length - 1; - }; - const getLiveHeadBoundaryIndex = (items: StreamItem[]) => { - if (items.length === 0) return null; - return inverted ? items.length - 1 : 0; - }; - return { - orderTail: (items) => (inverted ? [...items].toReversed() : items), - orderHead: (items) => (inverted ? [...items].toReversed() : items), - getNeighborIndex: (index, relation) => - relation === "above" ? index + neighborStep : index - neighborStep, - getNeighborItem: (items, index, relation) => { - const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep; - return items[neighborIndex]; - }, - getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex, - getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex, - getLatestItemIndex: getHistoryBoundaryIndex, - getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"), - } as StreamStrategy; + return resolveStreamRenderStrategy({ + platform: platform === "native" ? "android" : "web", + isMobileBreakpoint: false, + }); } function userMessage(id: string): Extract { @@ -71,7 +52,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..fde9a3a342 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([]); @@ -289,7 +289,7 @@ describe("layoutStream", () => { expect(layout.auxiliaryTurnFooter).toBeNull(); expect(assistantRow.completedFooter?.itemId).toBe(assistant.id); expect(assistantRow.belowItem?.id).toBe("u2"); - expect(assistantRow.frameOrder).toBe("footer-then-content"); + expect(assistantRow.frameOrder).toBe("content-then-footer"); }); it("keeps an accepted steer inline while its canonical turn is active", () => { 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/legend-list-held-row.browser.test.tsx b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx new file mode 100644 index 0000000000..a16763f467 --- /dev/null +++ b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx @@ -0,0 +1,190 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; +import React, { act, useSyncExternalStore } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { View } from "react-native"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch). +// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row. +// The native build applies its scroll adjustments through native scroll props that +// react-native-web ignores, so these tests read the offset LegendList holds, not the DOM's. + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const VIEWPORT_HEIGHT = 100; + +interface Row { + id: string; + height: number; +} + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + host.style.cssText = `display:flex;flex-direction:column;width:300px;height:${VIEWPORT_HEIGHT}px`; + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + grownHeights.clear(); + host.remove(); +}); + +function wait(ms: number): Promise { + return act(() => new Promise((resolve) => setTimeout(resolve, ms))); +} + +const LIST_STYLE = { flex: 1 }; +const rowStyles = new Map(); + +function rowStyle(height: number) { + let style = rowStyles.get(height); + if (!style) { + style = { height }; + rowStyles.set(height, style); + } + return style; +} + +// A row that changes its own height without a data change, like a todo card toggling its details. +const grownHeights = new Map(); +const heightListeners = new Set<() => void>(); + +function subscribeToHeights(listener: () => void) { + heightListeners.add(listener); + return () => heightListeners.delete(listener); +} + +function growRow(id: string, height: number) { + grownHeights.set(id, height); + for (const listener of heightListeners) listener(); +} + +function RowView({ row }: { row: Row }) { + const height = useSyncExternalStore( + subscribeToHeights, + () => grownHeights.get(row.id) ?? row.height, + ); + return ; +} + +function renderRow({ item }: { item: Row }) { + return ; +} + +function holdOnly(heldRowId: string) { + return { + data: true, + size: true, + shouldRestorePosition: (item: Row) => item.id === heldRowId, + }; +} + +function keyExtractor(item: Row) { + return item.id; +} + +function mountList(heldRowId: string) { + const listRef = React.createRef(); + const maintainVisibleContentPosition = holdOnly(heldRowId); + const render = (data: Row[]) => + act(() => + root.render( + , + ), + ); + const scrollElement = () => { + const scroller = [...host.querySelectorAll("div")].find( + (element) => element.scrollHeight > element.clientHeight + 1, + ); + if (!scroller) throw new Error("The list has no scroll element"); + return scroller; + }; + // Small steps: a large jump suspends LegendList's anchoring until the rows re-measure. + const scrollTo = async (offset: number) => { + for (let step = 20; step <= offset; step += 20) { + scrollElement().scrollTop = step; + await wait(150); + } + }; + const heldRowTop = () => { + const state = listRef.current?.getState(); + const position = state?.positionByKey(heldRowId); + if (state === undefined || position === undefined) { + throw new Error("The held row is not laid out"); + } + return position - state.scroll; + }; + const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId); + return { render, scrollTo, heldRowTop, rowSize }; +} + +it("anchors a data change on a held row whose top is above the viewport", async () => { + // The held row is a turn host whose prompt bubble is cut off at the top of the viewport, with a + // fully visible row below it. LegendList's ids in view start at that fully visible row. + const rows: Row[] = Array.from({ length: 10 }, (_, index) => ({ + id: `row-${index}`, + height: index === 3 ? 60 : 40, + })); + const list = mountList("row-3"); + list.render(rows); + await wait(150); + await list.scrollTo(140); + expect(list.heldRowTop()).toBe(-20); + + const inserted: Row = { id: "inserted", height: 30 }; + list.render([...rows.slice(0, 2), inserted, ...rows.slice(2)]); + await wait(150); + + expect(list.heldRowTop()).toBe(-20); +}); + +it("anchors a size change on a held row whose top is above the viewport", async () => { + // The same cut-off row grows from its own state, so LegendList sees a size change only. + const rows: Row[] = Array.from({ length: 10 }, (_, index) => ({ + id: `row-${index}`, + height: index === 3 ? 60 : 40, + })); + const list = mountList("row-3"); + list.render(rows); + await wait(150); + await list.scrollTo(140); + expect(list.heldRowTop()).toBe(-20); + + act(() => growRow("row-3", 260)); + await wait(150); + + expect(list.heldRowTop()).toBe(-20); +}); + +it("keeps measured row sizes while the list is not laid out", async () => { + // A hidden retained tab is display: none. Its rows still render, and a row measured there + // reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout + // event, so a 0 recorded while hidden would stay and the next row would overlap it. + const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 })); + const list = mountList("row-0"); + list.render(rows); + await wait(150); + expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]); + + host.style.display = "none"; + await wait(150); + list.render(rows.map((row) => ({ ...row }))); + await wait(150); + + expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]); +}); diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index dec4c489b9..bf6ce27f75 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -47,7 +47,7 @@ describe("buildAgentStreamRenderModel", () => { historyStart: 2, }); - expect(model.history.map((item) => item.id)).toEqual(["a3", "u3", "a2", "u2"]); + expect(model.history.map((item) => item.id)).toEqual(["u2", "a2", "u3", "a3"]); expect(model.segments.historyVirtualized).toHaveLength(0); }); @@ -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, }); @@ -98,7 +98,7 @@ describe("buildAgentStreamRenderModel", () => { historyStart, }); - expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]); + expect(model.history.map((item) => item.id)).toEqual(["visible-u", "visible-a"]); }); it("keeps head separate from committed history on desktop web", () => { @@ -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,13 +201,13 @@ 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, }); }); - it("derives the same timing for native inverted rendering", () => { + it("derives the same timing on native", () => { const tail = [userMessage("u1", 1), assistantMessage("a1", 4)]; const model = buildAgentStreamRenderModel({ @@ -219,8 +219,8 @@ describe("buildAgentStreamRenderModel", () => { isMobileBreakpoint: false, }); - expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); - expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ + expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["u1", "a1"]); + 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-disclosure-hold.test.ts b/packages/app/src/agent-stream/native-disclosure-hold.test.ts new file mode 100644 index 0000000000..b732edbb01 --- /dev/null +++ b/packages/app/src/agent-stream/native-disclosure-hold.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { createNativeDisclosureHold } from "./native-disclosure-hold"; + +function createFrames() { + let nextId = 1; + const pending = new Map void>(); + return { + requestFrame: (callback: () => void) => { + const id = nextId++; + pending.set(id, callback); + return id; + }, + cancelFrame: (id: number) => { + pending.delete(id); + }, + runFrame: () => { + const callbacks = [...pending.values()]; + pending.clear(); + for (const callback of callbacks) { + callback(); + } + }, + }; +} + +function createHarness() { + const frames = createFrames(); + const releases: string[] = []; + const hold = createNativeDisclosureHold({ + quietFrames: 2, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + onRelease: () => releases.push("released"), + }); + return { frames, hold, releases }; +} + +describe("native disclosure hold", () => { + it("holds the row until two frames pass without an item size change", () => { + const { frames, hold, releases } = createHarness(); + + hold.hold("turn-1"); + frames.runFrame(); + + expect(hold.heldRowId()).toBe("turn-1"); + expect(releases).toEqual([]); + + frames.runFrame(); + + expect(hold.heldRowId()).toBeNull(); + expect(releases).toEqual(["released"]); + }); + + it("restarts the quiet frames on every item size change", () => { + const { frames, hold, releases } = createHarness(); + + hold.hold("turn-1"); + frames.runFrame(); + hold.noteItemSizeChanged(); + frames.runFrame(); + + expect(releases).toEqual([]); + + frames.runFrame(); + + expect(releases).toEqual(["released"]); + }); + + it("ignores item size changes outside a hold", () => { + const { frames, hold, releases } = createHarness(); + + hold.noteItemSizeChanged(); + frames.runFrame(); + frames.runFrame(); + + expect(releases).toEqual([]); + }); + + it("moves the hold to the latest toggled row and releases once", () => { + const { frames, hold, releases } = createHarness(); + + hold.hold("turn-1"); + frames.runFrame(); + hold.hold("turn-2"); + + expect(hold.heldRowId()).toBe("turn-2"); + + frames.runFrame(); + frames.runFrame(); + + expect(releases).toEqual(["released"]); + }); + + it("drops the hold without releasing when cancelled", () => { + const { frames, hold, releases } = createHarness(); + + hold.hold("turn-1"); + hold.cancel(); + frames.runFrame(); + frames.runFrame(); + + expect(hold.heldRowId()).toBeNull(); + expect(releases).toEqual([]); + }); +}); diff --git a/packages/app/src/agent-stream/native-disclosure-hold.ts b/packages/app/src/agent-stream/native-disclosure-hold.ts new file mode 100644 index 0000000000..96b5087b98 --- /dev/null +++ b/packages/app/src/agent-stream/native-disclosure-hold.ts @@ -0,0 +1,64 @@ +/** + * The native viewport's disclosure hold. While a row is held, it is the only row the list may + * anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list + * measures the toggled row and any rows it inserted over one or more frames, and end following + * must not resume between the change and the list's last layout correction. + */ +export interface NativeDisclosureHold { + heldRowId(): string | null; + hold(rowId: string): void; + noteItemSizeChanged(): void; + cancel(): void; +} + +export function createNativeDisclosureHold(input: { + quietFrames: number; + requestFrame: (callback: () => void) => number; + cancelFrame: (id: number) => void; + onRelease: () => void; +}): NativeDisclosureHold { + let heldRowId: string | null = null; + let frameId: number | null = null; + let remainingFrames = 0; + + const stopCountdown = () => { + if (frameId !== null) { + input.cancelFrame(frameId); + frameId = null; + } + }; + + const tick = () => { + remainingFrames -= 1; + if (remainingFrames > 0) { + frameId = input.requestFrame(tick); + return; + } + frameId = null; + heldRowId = null; + input.onRelease(); + }; + + const restartCountdown = () => { + stopCountdown(); + remainingFrames = input.quietFrames; + frameId = input.requestFrame(tick); + }; + + return { + heldRowId: () => heldRowId, + hold(rowId) { + heldRowId = rowId; + restartCountdown(); + }, + noteItemSizeChanged() { + if (heldRowId !== null) { + restartCountdown(); + } + }, + cancel() { + stopCountdown(); + heldRowId = null; + }, + }; +} diff --git a/packages/app/src/agent-stream/native-end-pin.tsx b/packages/app/src/agent-stream/native-end-pin.tsx new file mode 100644 index 0000000000..dced2bf4a0 --- /dev/null +++ b/packages/app/src/agent-stream/native-end-pin.tsx @@ -0,0 +1,61 @@ +import { forwardRef, type ReactNode } from "react"; +import { + Animated, + type ScrollView, + type ScrollViewProps, + View, + type ViewStyle, +} from "react-native"; + +// While the stream follows its end, React Native's native content-position keeping holds a +// zero-height view at the bottom of the content still. It runs on the UI thread around each mount, +// so a row that grows or lands at the end scrolls in the same frame it lays out. LegendList's +// `maintainScrollAtEnd` scrolls from JS after the mount, which paints the growth below the viewport +// for a frame or more first. +// +// The anchor has to be the content's first native child: content-position keeping anchors on the +// first child at or after `minIndexForVisible` that reaches into the viewport, and every child +// LegendList renders can be flattened away. Absolute positioning keeps it out of the flow. +// +// A drag owns the offset from the frame it begins, but the pin stays until JS stops following a +// round trip later. The scroll view subclasses in `modules/paseo-scroll` recognise the pin by its +// native ID and skip its correction while the user drags, so a line mounting in those frames grows +// below the rows under the finger. +const END_PIN_NATIVE_ID = "paseo-end-pin"; +const END_ANCHOR_STYLE: ViewStyle = { + position: "absolute", + bottom: 0, + left: 0, + height: 0, + width: 0, +}; +const END_ANCHOR_POSITION = { minIndexForVisible: 0 }; + +interface EndPinnedScrollViewProps extends ScrollViewProps { + pinEnd: boolean; + children?: ReactNode; +} + +export const EndPinnedScrollView = forwardRef( + function EndPinnedScrollView({ pinEnd, children, maintainVisibleContentPosition, ...rest }, ref) { + return ( + + {pinEnd ? ( + + ) : null} + {children} + + ); + }, +); diff --git a/packages/app/src/agent-stream/native-scroll-animation.test.ts b/packages/app/src/agent-stream/native-scroll-animation.test.ts index 947b8c3d1d..10508154a8 100644 --- a/packages/app/src/agent-stream/native-scroll-animation.test.ts +++ b/packages/app/src/agent-stream/native-scroll-animation.test.ts @@ -4,31 +4,48 @@ 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 }), + shouldAnimateNativeScrollToBottom({ + distanceFromEnd: 60_000, + viewportHeight: 600, + drawDistance: 2000, + }), ).toBe(false); }); - it("keeps near-bottom movement eligible for animation", () => { + it("keeps near-end movement eligible for animation", () => { expect( - shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }), + shouldAnimateNativeScrollToBottom({ + distanceFromEnd: 300, + viewportHeight: 600, + drawDistance: 2000, + }), ).toBe(true); }); - it("bounds travel by the bottom-side overscan, not the whole window", () => { + it("bounds travel by the rows mounted below the viewport", () => { expect( - shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }), + shouldAnimateNativeScrollToBottom({ + distanceFromEnd: 1000, + viewportHeight: 600, + drawDistance: 2000, + }), ).toBe(true); expect( - shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }), - ).toBe(false); - expect( - shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }), + shouldAnimateNativeScrollToBottom({ + distanceFromEnd: 1001, + viewportHeight: 600, + drawDistance: 2000, + }), ).toBe(false); }); it("does not animate before the viewport has been measured", () => { expect( - shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }), + shouldAnimateNativeScrollToBottom({ + distanceFromEnd: 0, + viewportHeight: 0, + drawDistance: 2000, + }), ).toBe(false); }); }); diff --git a/packages/app/src/agent-stream/native-scroll-animation.ts b/packages/app/src/agent-stream/native-scroll-animation.ts index 72bc39108c..bd5a4966b6 100644 --- a/packages/app/src/agent-stream/native-scroll-animation.ts +++ b/packages/app/src/agent-stream/native-scroll-animation.ts @@ -1,10 +1,10 @@ export function shouldAnimateNativeScrollToBottom(input: { - offsetY: number; + distanceFromEnd: number; viewportHeight: number; - windowSize: number; + drawDistance: 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; + // Scrolled away from the end, LegendList keeps half its draw distance mounted below the + // viewport. An animated jump farther than that crosses rows it has not rendered. + const mountedBelowViewport = input.drawDistance / 2; + return input.viewportHeight > 0 && input.distanceFromEnd <= mountedBelowViewport; } diff --git a/packages/app/src/agent-stream/near-bottom.ts b/packages/app/src/agent-stream/near-bottom.ts new file mode 100644 index 0000000000..19de04b11a --- /dev/null +++ b/packages/app/src/agent-stream/near-bottom.ts @@ -0,0 +1,44 @@ +import { useState, useSyncExternalStore } from "react"; + +/** + * Whether the reader is at the end of the timeline. The viewport writes it and only the + * scroll-to-bottom button reads it, so detaching from the end (a scroll, or a disclosure + * that opens below its trigger) re-renders the button and not the timeline. + */ +export interface NearBottom { + set: (value: boolean) => void; + subscribe: (listener: () => void) => () => void; + get: () => boolean; +} + +function createNearBottom(): NearBottom { + let value = true; + const listeners = new Set<() => void>(); + return { + set: (next) => { + if (next === value) return; + value = next; + for (const listener of listeners) listener(); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + get: () => value, + }; +} + +/** One per viewed agent; a newly viewed agent starts at the end. */ +export function useNearBottom(agentId: string): NearBottom { + const [owner, setOwner] = useState(() => ({ agentId, nearBottom: createNearBottom() })); + if (owner.agentId === agentId) return owner.nearBottom; + const next = { agentId, nearBottom: createNearBottom() }; + setOwner(next); + return next.nearBottom; +} + +export function useIsNearBottom(nearBottom: NearBottom): boolean { + return useSyncExternalStore(nearBottom.subscribe, nearBottom.get, nearBottom.get); +} diff --git a/packages/app/src/agent-stream/new-row-estimates.test.ts b/packages/app/src/agent-stream/new-row-estimates.test.ts new file mode 100644 index 0000000000..d38c3fe3e0 --- /dev/null +++ b/packages/app/src/agent-stream/new-row-estimates.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { createNewRowEstimates } from "./new-row-estimates"; + +interface Row { + id: string; + kind: string; +} + +function row(id: string, kind = "assistant_message"): Row { + return { id, kind }; +} + +describe("new row estimates", () => { + it("leaves rows of the first list to the list's averages", () => { + const estimates = createNewRowEstimates(); + const first = row("a"); + + estimates.noteRows([first]); + + expect(estimates.estimate(first)).toBeUndefined(); + }); + + it("estimates a new row at the first measured size of the last new row of its kind", () => { + const estimates = createNewRowEstimates(); + estimates.noteRows([row("a")]); + const second = row("b"); + estimates.noteRows([row("a"), second]); + + expect(estimates.estimate(second)).toBeUndefined(); + + estimates.noteMeasured(second, 34); + estimates.noteMeasured(second, 56); + const third = row("c"); + estimates.noteRows([row("a"), second, third]); + + expect(estimates.estimate(third)).toBe(34); + }); + + it("keeps sizes per kind", () => { + const estimates = createNewRowEstimates(); + estimates.noteRows([]); + const text = row("a"); + const tool = row("b", "tool_call"); + estimates.noteRows([text, tool]); + estimates.noteMeasured(text, 34); + estimates.noteMeasured(tool, 36); + const nextTool = row("c", "tool_call"); + const nextText = row("d"); + estimates.noteRows([text, tool, nextTool, nextText]); + + expect(estimates.estimate(nextTool)).toBe(36); + expect(estimates.estimate(nextText)).toBe(34); + }); + + it("records the kind a row has when it is measured", () => { + const estimates = createNewRowEstimates(); + estimates.noteRows([]); + estimates.noteRows([row("a", "thought")]); + estimates.noteMeasured(row("a", "tool_call"), 36); + const thought = row("b", "thought"); + const tool = row("c", "tool_call"); + estimates.noteRows([row("a", "tool_call"), thought, tool]); + + expect(estimates.estimate(thought)).toBeUndefined(); + expect(estimates.estimate(tool)).toBe(36); + }); + + it("estimates new rows anywhere in the list", () => { + const estimates = createNewRowEstimates(); + estimates.noteRows([row("b"), row("d")]); + const appendedTool = row("e", "tool_call"); + estimates.noteRows([row("b"), row("d"), appendedTool]); + estimates.noteMeasured(appendedTool, 36); + const older = row("a", "tool_call"); + const revealed = row("c", "tool_call"); + estimates.noteRows([older, row("b"), revealed, row("d"), appendedTool]); + + expect(estimates.estimate(older)).toBe(36); + expect(estimates.estimate(revealed)).toBe(36); + expect(estimates.estimate(row("b", "tool_call"))).toBeUndefined(); + }); + + it("stops estimating a measured row", () => { + const estimates = createNewRowEstimates(); + estimates.noteRows([]); + const first = row("a"); + estimates.noteRows([first]); + estimates.noteMeasured(first, 34); + + expect(estimates.estimate(first)).toBeUndefined(); + }); +}); diff --git a/packages/app/src/agent-stream/new-row-estimates.ts b/packages/app/src/agent-stream/new-row-estimates.ts new file mode 100644 index 0000000000..f13a32697a --- /dev/null +++ b/packages/app/src/agent-stream/new-row-estimates.ts @@ -0,0 +1,47 @@ +interface EstimatedRow { + id: string; + kind: string; +} + +export interface NewRowEstimates { + /** Called with every row list; a row whose id the previous list did not have is new. */ + noteRows: (rows: readonly Row[]) => void; + /** Estimated size of an unmeasured new row, or undefined to use the list's average for its type. */ + estimate: (row: Row) => number | undefined; + /** A row can change kind under the same id (a thought becomes a tool call); its current kind counts. */ + noteMeasured: (row: Row, size: number) => void; +} + +// A row that enters the list is a streamed row at the end, a tool call an expanded summary reveals, +// or an older row loaded at the start. Its first measured size is close to constant per kind: one +// line of text, a tool call header, a thought line. So a new row is estimated at the first measured +// size of the last new row of its kind, and is laid out near its real size before it is measured. +// The list's average for a type is the size of whole turns and is far off for any of these. Rows of +// the first list are not new; they keep the average until measured. +export function createNewRowEstimates(): NewRowEstimates { + let previousRowIds: ReadonlySet | null = null; + const unmeasuredRowIds = new Set(); + const firstSizeByKind = new Map(); + + return { + noteRows(rows) { + const rowIds = new Set(rows.map((row) => row.id)); + if (previousRowIds) { + for (const row of rows) { + if (!previousRowIds.has(row.id)) unmeasuredRowIds.add(row.id); + } + } + for (const rowId of unmeasuredRowIds) { + if (!rowIds.has(rowId)) unmeasuredRowIds.delete(rowId); + } + previousRowIds = rowIds; + }, + estimate(row) { + return unmeasuredRowIds.has(row.id) ? firstSizeByKind.get(row.kind) : undefined; + }, + noteMeasured(row, size) { + if (!unmeasuredRowIds.delete(row.id)) return; + firstSizeByKind.set(row.kind, size); + }, + }; +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 357b6c079b..9f8213d716 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { buildAgentStreamRenderModel } from "./model"; import { createStreamPresentation } from "./presentation"; +import { createReadingAnchor } from "./reading-anchor"; const runtime = { paseo: {}, @@ -68,6 +69,9 @@ function installProbe( queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, + subscribe: () => { + throw new Error("Subscriptions are not used here"); + }, }; } @@ -588,3 +592,283 @@ describe("timeline presentation", () => { expect(projectTimelineItems([source], () => [])).toEqual([]); }); }); + +it("folds work before a plugin-rendered reply and leaves the reply visible", () => { + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Calculate", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp }, + ]; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")), + tail, + head: [], + display: "collapseTurns", + isTurnActive: false, + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]); + expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]); + expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]); +}); + +it("keeps streaming replies native, then folds work without losing manual expansion", () => { + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const work: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Calculate", timestamp }, + toolCall("one", "Bash"), + ]; + const reply: StreamItem = { + kind: "assistant_message", + id: "reply", + text: "$$x^2$$", + timestamp, + }; + const input = { + ...presentationOptions, + transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")), + display: "collapseTurns" as const, + }; + const present = createStreamPresentation(); + const live = present({ ...input, tail: work, head: [reply], isTurnActive: true }); + expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]); + const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false }; + expect(present(completed).tail.map((item) => item.id)).toEqual([ + "prompt", + "probe-plugin/reply/0", + ]); + const expandedTurnHostIds = new Set(["prompt"]); + const opened = present({ ...completed, expandedTurnHostIds }); + expect(opened.tail.map((item) => item.id)).toEqual([ + "prompt", + "tool-one", + "probe-plugin/reply/0", + ]); + expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false); + expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail); + expect(present(completed).tail.map((item) => item.id)).toEqual([ + "prompt", + "probe-plugin/reply/0", + ]); +}); + +it.each(["full", "collapseToolCalls"] as const)( + "leaves plugin replies and individual work visible in %s", + (display) => { + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Calculate", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp }, + ]; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: installedTransform(installProbe("assistant_message")), + tail, + head: [], + display, + isTurnActive: false, + }); + expect(result.tail.map((item) => item.id)).toEqual([ + "prompt", + "tool-one", + "probe-plugin/reply/0", + ]); + expect(result.turns.turnsByHostId.size).toBe(0); + }, +); + +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 9e8e5b430e..9a5ff6469e 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -1,3 +1,4 @@ +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"; @@ -10,19 +11,38 @@ import { } 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; display: ChatDisplay; + autoExpandReasoning?: boolean; /** User messages whose collapsed turn the reader opened. */ expandedTurnHostIds: ReadonlySet; isTurnActive: boolean; + activeTurnId?: string | null; } 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({ @@ -30,6 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, + previousTurnsByHostId: new Map(), }); /** Source messages reach plugins before display projection. */ @@ -55,27 +76,54 @@ 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]; } function collapse( projection: ToolCallDetailProjection, input: PresentationInput, - ): StreamPresentation { + ): 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 isLatestTurnOpen = input.isTurnActive || projection.head.length > 0; + 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 || @@ -87,13 +135,27 @@ export function createStreamPresentation() { isLatestTurnOpen, expandedHostIds: input.expandedTurnHostIds, getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls, + previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId, }); collapsedSource = projection.tail; collapsedIsLatestTurnOpen = isLatestTurnOpen; collapsedExpandedHostIds = input.expandedTurnHostIds; } const { tail, ...turns } = collapsed; - return { ...projection, tail, turns }; + 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 => { @@ -111,20 +173,48 @@ export function createStreamPresentation() { const blocks = presentRows(item); head.push(...blocks); } - if (preparedTail !== historyRows || preparedLevel !== level) { - preparedHistory = prepareToolCallHistory(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 = level; + preparedAutoExpandReasoning = input.autoExpandReasoning; } - return collapse( - projectToolCallDetailLevel({ - level, - tail: historyRows, - head, - preparedHistory, - isTurnActive: input.isTurnActive, - }), - input, + 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..30d30ab1a0 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 }, @@ -38,6 +56,16 @@ describe("reading anchor", () => { ).toBe(1100); }); + it("keeps a pinned row in place instead of the row under the reading line", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.pin(rows[2]!); + // The row being read grows; only the pinned row below it holds its position. + expect( + anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]), + ).toBe(900); + }); + it("leaves expansion of the row being read in place", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index 4299f1d9c8..ed2baa552a 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,27 +18,42 @@ 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, reset() { anchor = null; }, + // A disclosure names the row that owns its trigger. That row keeps its top + // through the toggle, even when another row sits under the reading line. + pin(row: Pick) { + anchor = { id: row.id, top: row.top }; + }, scroll(scrollTop: number) { 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..5e5ab7f7a2 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -12,7 +12,6 @@ import { isNearBottomForStreamRenderStrategy, orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy, - resolveBottomAnchorTransportBehavior, } from "./strategy"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; @@ -64,8 +63,6 @@ describe("resolveStreamRenderStrategy", () => { }); expect(strategy.shouldUseVirtualizedList()).toBe(false); - expect(strategy.getFlatListInverted()).toBe(false); - expect(strategy.getOverlayScrollbarInverted()).toBe(false); expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true); expect(strategy.getBottomAnchorTransportBehavior()).toEqual({ verificationDelayFrames: 0, @@ -73,57 +70,27 @@ describe("resolveStreamRenderStrategy", () => { }); }); - it("uses inverted_stream on native", () => { + it("uses a virtualized forward stream on native", () => { const strategy = resolveStreamRenderStrategy({ platform: "ios", isMobileBreakpoint: false, }); expect(strategy.shouldUseVirtualizedList()).toBe(true); - expect(strategy.getFlatListInverted()).toBe(true); - expect(strategy.getOverlayScrollbarInverted()).toBe(true); expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false); expect(strategy.getBottomAnchorTransportBehavior()).toEqual({ verificationDelayFrames: 2, verificationRetryMode: "recheck", }); }); - - it("delays native verification while viewport settling is in flight", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "ios", - isMobileBreakpoint: false, - }); - - expect( - resolveBottomAnchorTransportBehavior({ - strategy, - isViewportSettling: true, - }), - ).toEqual({ - verificationDelayFrames: 4, - verificationRetryMode: "recheck", - }); - }); - - it("does not inflate forward-stream verification delays during web resize", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - - expect( - resolveBottomAnchorTransportBehavior({ - strategy, - isViewportSettling: true, - }), - ).toEqual({ - verificationDelayFrames: 0, - verificationRetryMode: "rescroll", - }); - }); }); +const PLATFORMS = ["web", "ios", "android"] as const; + +function strategyFor(platform: (typeof PLATFORMS)[number]) { + return resolveStreamRenderStrategy({ platform, isMobileBreakpoint: false }); +} + describe("stream ordering", () => { const streamItems: StreamItem[] = [ userMessage("u1", "user-1", 1), @@ -131,11 +98,8 @@ describe("stream ordering", () => { assistantMessage("a2", "assistant-2", 3), ]; - it("keeps forward_stream order unchanged for tail and head", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); + it.each(PLATFORMS)("keeps oldest-to-newest order for tail and head on %s", (platform) => { + const strategy = strategyFor(platform); const tail = orderTailForStreamRenderStrategy({ strategy, streamItems }); const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems }); @@ -143,84 +107,42 @@ describe("stream ordering", () => { expect(tail.map((item) => item.id)).toEqual(["u1", "a1", "a2"]); expect(head.map((item) => item.id)).toEqual(["u1", "a1", "a2"]); }); - - it("reverses inverted_stream order for tail and head", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "android", - isMobileBreakpoint: false, - }); - - const tail = orderTailForStreamRenderStrategy({ strategy, streamItems }); - const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems }); - - expect(tail.map((item) => item.id)).toEqual(["a2", "a1", "u1"]); - expect(head.map((item) => item.id)).toEqual(["a2", "a1", "u1"]); - }); }); describe("neighbor and traversal semantics", () => { - it("maps above/below indices for forward and inverted streams", () => { - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - const inverted = resolveStreamRenderStrategy({ - platform: "ios", - isMobileBreakpoint: false, - }); + it.each(PLATFORMS)("maps above/below indices on %s", (platform) => { + const strategy = strategyFor(platform); - expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "above" })).toBe(2); - expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "below" })).toBe(4); - expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "above" })).toBe(4); - expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2); + expect(getStreamNeighborIndex({ strategy, index: 3, relation: "above" })).toBe(2); + expect(getStreamNeighborIndex({ strategy, index: 3, relation: "below" })).toBe(4); }); - it("collects assistant response content with strategy traversal direction", () => { + it.each(PLATFORMS)("collects assistant response content on %s", (platform) => { const chronological: StreamItem[] = [ userMessage("u1", "user-1", 1), assistantMessage("a1", "assistant-1", 2), assistantMessage("a2", "assistant-2", 3), userMessage("u2", "user-2", 4), ]; + const strategy = strategyFor(platform); + const items = orderTailForStreamRenderStrategy({ strategy, streamItems: chronological }); - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - const forwardStartIndex = chronological.findIndex((item) => item.id === "a2"); expect( collectAssistantResponseContentForStreamRenderStrategy({ - strategy: forward, - items: chronological, - startIndex: forwardStartIndex, - }), - ).toBe("assistant-1\n\nassistant-2"); - - const inverted = resolveStreamRenderStrategy({ - platform: "android", - isMobileBreakpoint: false, - }); - const invertedItems = orderTailForStreamRenderStrategy({ - strategy: inverted, - streamItems: chronological, - }); - const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2"); - expect( - collectAssistantResponseContentForStreamRenderStrategy({ - strategy: inverted, - items: invertedItems, - startIndex: invertedStartIndex, + strategy, + items, + startIndex: items.findIndex((item) => item.id === "a2"), }), ).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" }, { ...assistantMessage("a2", "second turn", 3), turnId: "turn-2" }, ]; - const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false }); + const strategy = strategyFor("web"); expect( collectAssistantResponseContentForStreamRenderStrategy({ @@ -228,41 +150,21 @@ 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", () => { - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); + const strategy = strategyFor("web"); const items: StreamItem[] = [userMessage("u1", "user-1", 1)]; - expect( - getStreamNeighborItem({ - strategy: forward, - items, - index: 0, - relation: "above", - }), - ).toBeUndefined(); - expect( - getStreamNeighborItem({ - strategy: forward, - items, - index: 0, - relation: "below", - }), - ).toBeUndefined(); + expect(getStreamNeighborItem({ strategy, items, index: 0, relation: "above" })).toBeUndefined(); + expect(getStreamNeighborItem({ strategy, items, index: 0, relation: "below" })).toBeUndefined(); }); }); describe("scroll/bottom calculations", () => { - it("computes near-bottom using forward_stream distance-from-bottom math", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); + it.each(PLATFORMS)("computes near-bottom from the distance to the end on %s", (platform) => { + const strategy = strategyFor(platform); expect( isNearBottomForStreamRenderStrategy({ @@ -284,48 +186,10 @@ describe("scroll/bottom calculations", () => { ).toBe(false); }); - it("computes near-bottom and scroll-to-bottom offset for inverted_stream", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "ios", - isMobileBreakpoint: false, - }); - - expect( - isNearBottomForStreamRenderStrategy({ - strategy, - offsetY: 12, - viewportHeight: 300, - contentHeight: 1000, - threshold: 24, - }), - ).toBe(true); - expect( - isNearBottomForStreamRenderStrategy({ - strategy, - offsetY: 40, - viewportHeight: 300, - contentHeight: 1000, - threshold: 24, - }), - ).toBe(false); + it.each(PLATFORMS)("maps scroll-to-bottom to the max offset on %s", (platform) => { expect( getBottomOffsetForStreamRenderStrategy({ - strategy, - viewportHeight: 300, - contentHeight: 1000, - }), - ).toBe(0); - }); - - it("maps scroll-to-bottom to max offset for forward_stream", () => { - const strategy = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - - expect( - getBottomOffsetForStreamRenderStrategy({ - strategy, + strategy: strategyFor(platform), viewportHeight: 320, contentHeight: 1000, }), @@ -334,36 +198,15 @@ describe("scroll/bottom calculations", () => { }); describe("edge slot semantics", () => { - it("uses footer slot for forward_stream and header slot for inverted_stream", () => { + it.each(PLATFORMS)("uses the footer slot on %s", (platform) => { const EdgeSlot = () => null; - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - const inverted = resolveStreamRenderStrategy({ - platform: "android", - isMobileBreakpoint: false, - }); - const forwardProps = getStreamEdgeSlotProps({ - strategy: forward, - component: EdgeSlot, - gapSize: 4, - }); - const invertedProps = getStreamEdgeSlotProps({ - strategy: inverted, - component: EdgeSlot, - gapSize: 4, - }); - - expect(forwardProps).toEqual({ + expect( + getStreamEdgeSlotProps({ strategy: strategyFor(platform), component: EdgeSlot, gapSize: 4 }), + ).toEqual({ ListFooterComponent: EdgeSlot, ListFooterComponentStyle: { marginTop: 4 }, }); - expect(invertedProps).toEqual({ - ListHeaderComponent: EdgeSlot, - ListHeaderComponentStyle: { marginBottom: 4 }, - }); }); }); @@ -373,81 +216,31 @@ describe("layout strategy edges", () => { assistantMessage("a1", "assistant-1", 2), ]; - it("uses the newest history edge as the history/live boundary", () => { - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - const inverted = resolveStreamRenderStrategy({ - platform: "android", - isMobileBreakpoint: false, - }); + it.each(PLATFORMS)( + "uses the newest history edge as the history/live boundary on %s", + (platform) => { + const strategy = strategyFor(platform); + const history = orderTailForStreamRenderStrategy({ strategy, streamItems }); - const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems }); - const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems }); + expect(getHistoryLiveBoundaryIndexForStreamRenderStrategy({ strategy, history })).toBe(1); + }, + ); - expect( - getHistoryLiveBoundaryIndexForStreamRenderStrategy({ - strategy: forward, - history: forwardHistory, - }), - ).toBe(1); - expect( - getHistoryLiveBoundaryIndexForStreamRenderStrategy({ - strategy: inverted, - history: invertedHistory, - }), - ).toBe(0); - }); + it.each(PLATFORMS)( + "uses the oldest live-head edge as the live-head/history boundary on %s", + (platform) => { + const strategy = strategyFor(platform); + const liveHead = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems }); - it("uses the oldest live-head edge as the live-head/history boundary", () => { - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - const inverted = resolveStreamRenderStrategy({ - platform: "ios", - isMobileBreakpoint: false, - }); + expect(getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({ strategy, liveHead })).toBe( + 0, + ); + }, + ); - const forwardHead = orderHeadForStreamRenderStrategy({ - strategy: forward, - streamHead: streamItems, - }); - const invertedHead = orderHeadForStreamRenderStrategy({ - strategy: inverted, - streamHead: streamItems, - }); - - expect( - getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({ - strategy: forward, - liveHead: forwardHead, - }), - ).toBe(0); - expect( - getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({ - strategy: inverted, - liveHead: invertedHead, - }), - ).toBe(1); - }); - - it("names the frame child order needed by native inverted cells", () => { - const forward = resolveStreamRenderStrategy({ - platform: "web", - isMobileBreakpoint: false, - }); - const inverted = resolveStreamRenderStrategy({ - platform: "android", - isMobileBreakpoint: false, - }); - - expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe( + it.each(PLATFORMS)("renders a cell's content before its footer on %s", (platform) => { + expect(getFrameChildOrderForStreamRenderStrategy({ strategy: strategyFor(platform) })).toBe( "content-then-footer", ); - expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe( - "footer-then-content", - ); }); }); diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts index 9d22d2b90c..d459ca8ba8 100644 --- a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts @@ -41,23 +41,23 @@ describe("scroll keyboard dismissal", () => { shouldDismiss( { ts: 1000, y: 0 }, [ - { ts: 1040, y: 8 }, - { ts: 1080, y: 16 }, - { ts: 1120, y: 24 }, + { ts: 1040, y: -8 }, + { ts: 1080, y: -16 }, + { ts: 1120, y: -24 }, ], - { ts: 1160, y: 32 }, + { ts: 1160, y: -32 }, ), ).toBe(false); }); - it("dismisses for an upward flick", () => { - expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe( + it("dismisses for a flick toward older messages", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe( true, ); }); - it("keeps the keyboard up when the inverted list moves toward newer messages", () => { - expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe( + it("keeps the keyboard up when the list moves toward newer messages", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe( false, ); }); @@ -67,17 +67,17 @@ describe("scroll keyboard dismissal", () => { shouldDismiss( { ts: 1000, y: 0 }, [ - { ts: 1100, y: 500 }, - { ts: 1200, y: 590 }, - { ts: 1270, y: 598 }, + { ts: 1100, y: -500 }, + { ts: 1200, y: -590 }, + { ts: 1270, y: -598 }, ], - { ts: 1300, y: 600 }, + { ts: 1300, y: -600 }, ), ).toBe(false); }); it("uses the whole drag when the gesture is too short to sample", () => { - expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true); + expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: -60 })).toBe(true); }); it("steps back a sample when release lands immediately after one", () => { @@ -85,22 +85,22 @@ describe("scroll keyboard dismissal", () => { shouldDismiss( { ts: 1000, y: 0 }, [ - { ts: 1040, y: 120 }, - { ts: 1075, y: 225 }, + { ts: 1040, y: -120 }, + { ts: 1075, y: -225 }, ], - { ts: 1080, y: 240 }, + { ts: 1080, y: -240 }, ), ).toBe(true); }); it("keeps the keyboard up when release has no measurable time span", () => { - expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false); + expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: -90 })).toBe(false); }); it("ignores scroll events that arrive before the minimum sample span", () => { - expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe( - true, - ); + expect( + shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: -1000 }], { ts: 1060, y: -1000 }), + ).toBe(true); }); it("uses native gesture time when delayed callbacks arrive in a burst", () => { @@ -108,32 +108,32 @@ describe("scroll keyboard dismissal", () => { shouldDismiss( { ts: 5000, nativeTs: 1000, y: 0 }, [ - { ts: 5001, nativeTs: 1200, y: 40 }, - { ts: 5002, nativeTs: 1400, y: 80 }, + { ts: 5001, nativeTs: 1200, y: -40 }, + { ts: 5002, nativeTs: 1400, y: -80 }, ], - { ts: 5003, nativeTs: 1600, y: 120 }, + { ts: 5003, nativeTs: 1600, y: -120 }, ), ).toBe(false); }); it("falls back to synthetic event time when native time is absent", () => { expect( - shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], { + shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: -120 }], { ts: 1080, nativeTs: null, - y: 240, + y: -240, }), ).toBe(true); }); it("ignores scroll and release events without a matching drag", () => { - const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 })); + const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: -200 })); expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); - expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false); + expect(releaseDrag(idle, event({ ts: 1010, y: -300 })).shouldDismiss).toBe(false); }); it("returns to idle after release", () => { - const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 })); + const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: -60 })); expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); }); }); diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts index 73e0d19a76..374bb84762 100644 --- a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts @@ -2,8 +2,7 @@ * Pure state machine for the chat history's flick-to-dismiss gesture. * * Native keyboardDismissMode cannot express this interaction: "on-drag" - * dismisses on the first pixel, while "interactive" behaves incorrectly on an - * inverted list. We therefore classify the list's existing scroll gesture at + * dismisses on the first pixel, while "interactive" is iOS-only. We therefore classify the list's existing scroll gesture at * release instead of introducing a competing pan recognizer. */ @@ -114,11 +113,12 @@ export function releaseDrag( spanStartY = samples.previousSampleY; } + // The list runs oldest to newest, so moving toward older messages lowers the offset. const spanDurationMs = releaseTs - spanStartTs; - const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0; + const towardOlderVelocity = spanDurationMs > 0 ? (spanStartY - releaseY) / spanDurationMs : 0; return { gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, - shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS, + shouldDismiss: towardOlderVelocity > DISMISS_VELOCITY_POINTS_PER_MS, }; } diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts index fb78eea9b5..e0455687ea 100644 --- a/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts @@ -18,7 +18,7 @@ type ScrollEvent = NativeSyntheticEvent; /** * Owns the chat history's flick-to-dismiss behavior. The native stream only - * forwards the FlatList scroll lifecycle; removing this hook and those three + * forwards the LegendList scroll lifecycle; removing this hook and those three * calls removes the feature completely. */ export function useScrollKeyboardDismiss() { diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 7dc6e8d521..3288e2be25 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -1,38 +1,42 @@ import { - Fragment, type ReactElement, + type ReactNode, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from "react"; import { - FlatList, - Platform, View, type LayoutChangeEvent, - type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, + type ScrollView, + type ScrollViewProps, type ViewStyle, } from "react-native"; +import { + LegendList, + type LegendListRef, + type LegendListRenderItemProps, + useSyncLayout, +} from "@legendapp/list/react-native"; import { withUnistyles } from "react-native-unistyles"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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"; +import { createNativeDisclosureHold } from "./native-disclosure-hold"; +import { EndPinnedScrollView } from "./native-end-pin"; +import { createNewRowEstimates } from "./new-row-estimates"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; -import { - createStreamStrategy, - isNearBottomForStreamRenderStrategy, - resolveBottomAnchorTransportBehavior, -} from "./strategy"; +import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy"; import { abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, @@ -49,11 +53,6 @@ import { type HistoryStartSettleScheduler, } from "./history-start-settle-scheduler"; -const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ - minIndexForVisible: 0, - autoscrollToTopThreshold: 0, -}); - const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, @@ -65,7 +64,50 @@ const historyStartSlotStyle: ViewStyle = { flexShrink: 0, }; const HISTORY_START_SETTLE_FRAMES = 2; -const NATIVE_STREAM_WINDOW_SIZE = 10; +// Rendered distance on each side of the viewport. Smaller values blank the viewport while a fling +// crosses rows that were never measured (docs/agent-stream-disclosure.md). +const NATIVE_STREAM_DRAW_DISTANCE = 2000; +// Following resumes after a disclosure or a user scroll only from the end, so resuming never moves +// the rows the user is looking at. Web resumes within the same distance (strategy-web.tsx). +const AT_END_OFFSET_PX = 1; +// A disclosure settles after this many frames without an item size change. +const DISCLOSURE_QUIET_FRAMES = 2; +const BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR = Object.freeze({ + verificationDelayFrames: 2, + verificationRetryMode: "recheck" as const, +}); + +type RowLayoutSyncRegistry = Map void>; + +// A disclosure toggle changes a row from the row's own state, so LegendList's container does not +// re-render and would learn the new height from onLayout a task later, a frame after the change +// painted. The row's measurement trigger, called next to the toggle, batches the measurement and +// the corrected positions into the toggle's own commit. +function RowLayoutSync(props: { + rowId: string; + registry: RowLayoutSyncRegistry; + children: ReactNode; +}) { + const { rowId, registry } = props; + const syncLayout = useSyncLayout(); + useLayoutEffect(() => { + registry.set(rowId, syncLayout); + return () => { + if (registry.get(rowId) === syncLayout) { + registry.delete(rowId); + } + }; + }, [registry, rowId, syncLayout]); + return props.children; +} + +function distanceFromEnd(input: { + offsetY: number; + contentHeight: number; + viewportHeight: number; +}): number { + return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY); +} function keyExtractor(item: { id: string }): string { return item.id; @@ -94,7 +136,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat strategy, } = props; const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; - const flatListRef = useRef>(null); + const listRef = useRef(null); + // LegendList places the rows once, behind a hidden reveal, when the list mounts. + const [isListPlaced, setIsListPlaced] = useState(false); + const isListPlacedRef = useRef(isListPlaced); + isListPlacedRef.current = isListPlaced; const streamViewportMetricsRef = useRef({ containerKey: "native-virtualized", contentHeight: 0, @@ -107,11 +153,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const scrollOffsetYRef = useRef(0); const isUserScrollActiveRef = useRef(false); const scrollKeyboardDismiss = useScrollKeyboardDismiss(); - const streamKeyboardInset = useKeyboardStreamInset(); const userScrollEndFrameIdRef = useRef(null); + const isReleasedAtEndRef = useRef(false); const programmaticScrollEventBudgetRef = useRef(0); - const isNativeViewportSettlingRef = useRef(false); - const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); const [historyStartPaginationState, setHistoryStartPaginationState] = useState( createHistoryStartPaginationState, @@ -126,13 +170,28 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return [...segments.historyVirtualized, ...segments.historyMounted]; }, [segments.historyMounted, segments.historyVirtualized]); const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision); + // Live rows are the tail of the list, so a row that finishes keeps its id, container and measured + // size when it moves into history. A fresh identity per live-head change re-renders every live + // row, which is how they pick up neighbour and expansion changes (`liveHeadRowRevision`). + const liveRows = useMemo(() => { + void liveHeadRowRevision; + return segments.liveHead.map((item) => ({ ...item })); + }, [segments.liveHead, liveHeadRowRevision]); + const liveRowIds = useMemo(() => new Set(liveRows.map((item) => item.id)), [liveRows]); + const [newRowEstimates] = useState(() => createNewRowEstimates()); + const rows = useMemo(() => { + const nextRows = [...historyRows, ...liveRows]; + newRowEstimates.noteRows(nextRows); + return nextRows; + }, [newRowEstimates, historyRows, liveRows]); + const getEstimatedItemSize = useStableEvent((item: StreamItem) => newRowEstimates.estimate(item)); const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => { const metrics = streamViewportMetricsRef.current; const hasMeasuredViewport = metrics.viewportMeasuredForKey === metrics.containerKey && metrics.contentMeasuredForKey === metrics.containerKey; return { - distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY, + distanceFromHistoryStart: metrics.offsetY, hasOlderHistory, isLoadingOlderHistory, isReady: historyStartReadyRef.current && hasMeasuredViewport, @@ -196,13 +255,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scheduler.schedule(); }); - const clearNativeViewportSettling = useCallback(() => { - if (nativeViewportSettlingFrameIdRef.current !== null) { - cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current); - nativeViewportSettlingFrameIdRef.current = null; - } - }, []); - const clearPendingUserScrollEnd = useCallback(() => { if (userScrollEndFrameIdRef.current !== null) { cancelAnimationFrame(userScrollEndFrameIdRef.current); @@ -210,49 +262,30 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }, []); - const markNativeViewportSettling = useCallback(() => { - clearNativeViewportSettling(); - isNativeViewportSettlingRef.current = true; - let remainingFrames = 4; - const tick = () => { - if (remainingFrames <= 0) { - nativeViewportSettlingFrameIdRef.current = null; - isNativeViewportSettlingRef.current = false; - return; - } - remainingFrames -= 1; - nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); - }; - nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); - }, [clearNativeViewportSettling]); - - const getBottomAnchorTransportBehavior = useCallback( - () => - resolveBottomAnchorTransportBehavior({ - strategy, - isViewportSettling: isNativeViewportSettlingRef.current, - }), - [strategy], - ); + const getBottomAnchorTransportBehavior = useCallback(() => BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR, []); const scrollToBottom = useCallback( (animated: boolean) => { programmaticScrollEventBudgetRef.current = 3; - flatListRef.current?.scrollToOffset({ - offset: 0, + const metrics = streamViewportMetricsRef.current; + // The native scroll view finds its end from the content it has mounted, and the end pin holds + // it from there. LegendList's own scrollToEnd keeps re-aiming at the end its JS sizes predict + // until it lands, which runs ahead of the mount and scrolls the end out of view. + const list = listRef.current; + list?.cancelScroll(); + // The scroll component is `EndPinnedScrollView`, a ScrollView. + (list?.getNativeScrollRef() as ScrollView | undefined)?.scrollToEnd({ animated: animated && shouldAnimateNativeScrollToBottom({ - offsetY: scrollOffsetYRef.current, - viewportHeight: streamViewportMetricsRef.current.viewportHeight, - windowSize: NATIVE_STREAM_WINDOW_SIZE, + distanceFromEnd: distanceFromEnd(metrics), + viewportHeight: metrics.viewportHeight, + drawDistance: NATIVE_STREAM_DRAW_DISTANCE, }), }); - scrollOffsetYRef.current = 0; - streamViewportMetricsRef.current = { - ...streamViewportMetricsRef.current, - offsetY: 0, - }; + const endOffsetY = Math.max(0, metrics.contentHeight - metrics.viewportHeight); + scrollOffsetYRef.current = endOffsetY; + streamViewportMetricsRef.current = { ...metrics, offsetY: endOffsetY }; onNearBottomChange(true); }, [onNearBottomChange], @@ -262,7 +295,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat agentId, routeRequest: routeBottomAnchorRequest, isAuthoritativeHistoryReady, - renderStrategy: "inverted-stream", + renderStrategy: "legend-stream", getTransportBehavior: getBottomAnchorTransportBehavior, getMeasurementState: () => streamViewportMetricsRef.current, isNearBottom: () => { @@ -277,60 +310,79 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }, scrollToBottom, }); - // Android's maintainVisibleContentPosition ignores the list inversion transform and - // fights the controller's offset-zero correction while the live header grows. - const maintainVisibleContentPosition = - Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom" - ? undefined - : DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION; - const topOverlayInset = useStreamTopOverlayInset(); - // The list is inverted, so its content's bottom padding is the visual top. - const listContentContainerStyle = useMemo( - () => [ - baseListContentContainerStyle, - { - paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset, - }, - ], - [ - baseListContentContainerStyle, - streamKeyboardInset.contentContainerPaddingBottom, - topOverlayInset, - ], + + const [rowLayoutSyncRegistry] = useState(() => new Map()); + const isAtEnd = useStableEvent( + () => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX, ); - const listInsetProps = useMemo( - () => - streamKeyboardInset.contentInset - ? { - automaticallyAdjustContentInsets: false, - automaticallyAdjustsScrollIndicatorInsets: false, - contentInsetAdjustmentBehavior: "never" as const, - contentInset: streamKeyboardInset.contentInset, - scrollIndicatorInsets: streamKeyboardInset.contentInset, - } - : {}, - [streamKeyboardInset.contentInset], + const releaseDisclosure = useStableEvent(() => { + bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() }); + }); + const [disclosureHold] = useState(() => + createNativeDisclosureHold({ + quietFrames: DISCLOSURE_QUIET_FRAMES, + requestFrame: requestAnimationFrame, + cancelFrame: cancelAnimationFrame, + onRelease: releaseDisclosure, + }), + ); + useEffect(() => () => disclosureHold.cancel(), [disclosureHold]); + // The hold detaches the controller in the same event as `apply`, so the toggle's commit already + // has end following off and LegendList anchoring on the held row. The row's measurement trigger + // makes that same commit carry the row's new height. + const holdRow = useStableEvent((rowId: string, apply: () => void) => { + disclosureHold.hold(rowId); + bottomAnchorController.holdForDisclosure(); + apply(); + rowLayoutSyncRegistry.get(rowId)?.(); + }); + const handleItemSizeChanged = useStableEvent( + (info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => { + newRowEstimates.noteMeasured(info.itemData, info.size); + disclosureHold.noteItemSizeChanged(); + }, + ); + const shouldRestorePosition = useStableEvent((item: StreamItem) => { + const heldRowId = disclosureHold.heldRowId(); + return heldRowId === null || item.id === heldRowId; + }); + // One owner keeps the scroll offset at any moment: + // - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It + // keeps the rows hidden until they are measured and in place, so neither the pin nor the + // controller acts; the content does not count as measured yet (`handleContentSizeChange`). + // - Following the end: the end pin. Rows added, rows resized and footer changes never scroll from + // JS; the pin holds the end in the frame the change mounts. The controller scrolls only for a + // viewport resize (the composer growing or shrinking), which the pin cannot see, and for an + // explicit jump, and both go to the native end (`scrollToBottom`). The keyboard does not resize + // the viewport: it translates the list with the composer. LegendList's end keeping stays off: it + // scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands + // short of the real end and the controller reads that as the user scrolling away. + // - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and + // LegendList keeps only the held row in place. + // - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below + // the rows the user is dragging, and resumes following if the gesture ends at the end. + // - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading + // above or rows growing below leave what the user is reading where it is. + const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom"; + const pinsEnd = isFollowingEnd && isListPlaced; + const maintainVisibleContentPosition = useMemo( + () => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }), + [isFollowingEnd, shouldRestorePosition], + ); + // A new identity re-renders LegendList's scroll component, which is how the pin follows the mode. + const renderScrollComponent = useCallback( + (scrollProps: ScrollViewProps) => , + [pinsEnd], + ); + const topOverlayInset = useStreamTopOverlayInset(); + // No keyboard inset: one committed after the keyboard settles moves every row when the end pin is + // not holding the offset (docs/agent-stream-disclosure.md). + const listContentContainerStyle = useMemo( + () => [baseListContentContainerStyle, { paddingTop: topOverlayInset }], + [baseListContentContainerStyle, topOverlayInset], ); useEffect(() => { - streamViewportMetricsRef.current = { - containerKey: "native-virtualized", - contentHeight: 0, - viewportWidth: 0, - viewportHeight: 0, - offsetY: 0, - viewportMeasuredForKey: null, - contentMeasuredForKey: null, - }; - scrollOffsetYRef.current = 0; - isUserScrollActiveRef.current = false; - clearPendingUserScrollEnd(); - clearNativeViewportSettling(); - isNativeViewportSettlingRef.current = false; - historyStartReadyRef.current = false; - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -341,13 +393,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); - - useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]); - - useEffect(() => { - bottomAnchorController.prepareForStickyContentChange(); - }, [bottomAnchorController, historyRows, segments.liveHead]); + }, [clearPendingUserScrollEnd, evaluateHistoryStart]); useEffect(() => { const handle: StreamViewportHandle = { @@ -359,9 +405,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }, prepareForViewportChange: () => { bottomAnchorController.prepareForStickyViewportChange(); - markNativeViewportSettling(); }, - holdReadingPosition: () => bottomAnchorController.detachByUser(), + holdRow, }; viewportRef.current = handle; return () => { @@ -369,7 +414,32 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportRef.current = null; } }; - }, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); + }, [agentId, bottomAnchorController, holdRow, viewportRef]); + + // The scroll-to-bottom button shows whenever following would not resume on its own. A detached + // list resumes only from the end, so the button shows anywhere short of it. While following, the + // offset can trail content growth for a frame, so the controller's wider threshold keeps the + // button from flashing. + const publishNearBottom = useStableEvent(() => { + const metrics = streamViewportMetricsRef.current; + onNearBottomChange( + bottomAnchorController.mode === "detached" + ? distanceFromEnd(metrics) <= AT_END_OFFSET_PX + : isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: metrics.offsetY, + threshold: 32, + contentHeight: metrics.contentHeight, + viewportHeight: metrics.viewportHeight, + }), + ); + }); + + // Following starts and stops without a scroll, for example when a disclosure hold begins short of + // the end, and the button follows it. + useEffect(() => { + publishNearBottom(); + }, [isFollowingEnd, publishNearBottom]); const isScrollEventNearBottom = useStableEvent( (event: NativeSyntheticEvent) => { @@ -384,6 +454,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }, ); + const isScrollEventAtEnd = useStableEvent((event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + return ( + distanceFromEnd({ + offsetY: contentOffset.y, + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + }) <= AT_END_OFFSET_PX + ); + }); + const handleScroll = useStableEvent((event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; @@ -401,14 +482,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; const nearBottom = isScrollEventNearBottom(event); - onNearBottomChange(nearBottom); + publishNearBottom(); evaluateHistoryStart(); if ( !isUserScrollActiveRef.current && programmaticScrollEventBudgetRef.current > 0 && - contentOffset.y <= 8 + distanceFromEnd({ + offsetY: contentOffset.y, + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + }) <= 8 ) { programmaticScrollEventBudgetRef.current -= 1; } else { @@ -424,6 +509,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); + // The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can + // keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave + // no row LegendList may anchor a history page on. The gesture's end decides following. + disclosureHold.cancel(); bottomAnchorController.beginUserScroll(); const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); if (rearmed !== historyStartPaginationStateRef.current) { @@ -433,17 +522,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }); - // Defer drag end so momentum can take ownership, but capture the terminal - // gesture position now because layout may move the viewport in the meantime. + // A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there. + // A release past the end bounces back to it on iOS, and lines that land during the bounce scroll + // in as they do while following. Momentum stopping short of the end, or output that arrived below + // the viewport before the decision, leaves the list detached. + // Drag end is deferred a frame so momentum can take ownership. const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent) => { - const isNearBottom = isScrollEventNearBottom(event); scrollKeyboardDismiss.onScrollEndDrag(event); + isReleasedAtEndRef.current = isScrollEventAtEnd(event); clearPendingUserScrollEnd(); userScrollEndFrameIdRef.current = requestAnimationFrame(() => { userScrollEndFrameIdRef.current = null; isUserScrollActiveRef.current = false; - bottomAnchorController.endUserScroll({ isNearBottom }); + bottomAnchorController.endUserScroll({ + isAtEnd: isReleasedAtEndRef.current || isAtEnd(), + }); }); }); @@ -458,10 +552,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat if (!isUserScrollActiveRef.current) { return; } - const isNearBottom = isScrollEventNearBottom(event); clearPendingUserScrollEnd(); isUserScrollActiveRef.current = false; - bottomAnchorController.endUserScroll({ isNearBottom }); + bottomAnchorController.endUserScroll({ + isAtEnd: isReleasedAtEndRef.current || isScrollEventAtEnd(event), + }); }, ); @@ -470,9 +565,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; const viewportWidth = Math.max(0, event.nativeEvent.layout.width); const viewportHeight = Math.max(0, event.nativeEvent.layout.height); - const viewportChanged = - (previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) || - (previousViewportHeight > 0 && previousViewportHeight !== viewportHeight); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, containerKey: "native-virtualized", @@ -480,9 +572,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportHeight, viewportMeasuredForKey: "native-virtualized", }; - if (viewportChanged) { - markNativeViewportSettling(); - } bottomAnchorController.handleViewportMetricsChange({ previousViewportWidth, viewportWidth, @@ -493,24 +582,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleContentSizeChange = useStableEvent((_width: number, height: number) => { - const previousContentHeight = streamViewportMetricsRef.current.contentHeight; const nextContentHeight = Math.max(0, height); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, containerKey: "native-virtualized", contentHeight: nextContentHeight, - contentMeasuredForKey: "native-virtualized", + contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null, }; - bottomAnchorController.handleContentSizeChange({ - previousContentHeight, - contentHeight: nextContentHeight, - }); + bottomAnchorController.handleContentSizeChange(); + // Content growing below a detached viewport moves the end away without a scroll event. + if ( + bottomAnchorController.mode === "detached" && + streamViewportMetricsRef.current.viewportHeight > 0 + ) { + publishNearBottom(); + } evaluateHistoryStart(); if (historyStartPaginationStateRef.current.status === "settling") { scheduleHistoryStartSettle(); } }); + const handleListPlaced = useStableEvent(() => { + isListPlacedRef.current = true; + setIsListPlaced(true); + streamViewportMetricsRef.current = { + ...streamViewportMetricsRef.current, + contentMeasuredForKey: "native-virtualized", + }; + bottomAnchorController.handleContentSizeChange(); + }); + useEffect(() => { evaluateHistoryStart(); if (historyStartPaginationStateRef.current.status === "settling") { @@ -524,45 +626,35 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scheduleHistoryStartSettle, ]); + // LegendList re-renders a mounted row only when its item or `extraData` changes, not when + // renderItem does. Item identity carries content and display changes (`useRevisedHistoryRows`); + // `extraData` carries the history renderer, whose identity changes with the history layout (a + // reply gaining its completed footer when the next prompt arrives) and keeps its row's item. + // StreamRow's memo limits that pass to the rows whose layout item changed. const renderItem = useStableEvent( - ({ item, index }: ListRenderItemInfo): ReactElement | null => { - const rendered = renderHistoryMountedRow(item, index, historyItems); - return (rendered ?? null) as ReactElement | null; - }, + ({ item, index }: LegendListRenderItemProps): ReactElement => ( + + {liveRowIds.has(item.id) + ? renderLiveHeadRow(item, index - historyRows.length, segments.liveHead) + : renderHistoryMountedRow(item, index, historyItems)} + + ), ); - const liveHeaderContent = useMemo(() => { - // Stable render events read the latest expansion state; this revision makes - // the memo invoke them again when that state changes. - void liveHeadRowRevision; - const liveHeadRows = segments.liveHead.map((item, index) => ( - {renderLiveHeadRow(item, index, segments.liveHead)} - )); + const liveAuxiliaryContent = useMemo(() => { const liveAuxiliary = renderLiveAuxiliary(); if ( - liveHeadRows.length === 0 && + liveRows.length === 0 && !liveAuxiliary && !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory ) { return (listEmptyComponent ?? null) as ReactElement | null; } - return ( - - {liveHeadRows} - {liveAuxiliary} - - ); - }, [ - boundary, - listEmptyComponent, - liveHeadRowRevision, - renderLiveAuxiliary, - renderLiveHeadRow, - segments.liveHead, - ]); + return {liveAuxiliary}; + }, [boundary, listEmptyComponent, liveRows.length, renderLiveAuxiliary]); - const historyFooterContent = useMemo(() => { + const historyStartContent = useMemo(() => { const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( @@ -575,21 +667,21 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ); }, [historyStartPaginationState]); - // RN's FlatList strictMode keeps its internal renderItem wrapper stable when - // data or the live header changes, preserving the row identities above. return ( - + ref={listRef} + data={rows} renderItem={renderItem} + extraData={renderHistoryMountedRow} keyExtractor={keyExtractor} - strictMode testID="agent-chat-scroll" nativeID="agent-chat-scroll-native-virtualized" - ListHeaderComponent={liveHeaderContent ?? undefined} - ListFooterComponent={historyFooterContent ?? undefined} + ListHeaderComponent={historyStartContent} + ListFooterComponent={liveAuxiliaryContent ?? undefined} contentContainerStyle={listContentContainerStyle} + automaticallyAdjustContentInsets={false} + automaticallyAdjustsScrollIndicatorInsets={false} + contentInsetAdjustmentBehavior="never" style={listStyle} onLayout={handleListLayout} onScroll={handleScroll} @@ -599,16 +691,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} + onItemSizeChanged={handleItemSizeChanged} + onReady={handleListPlaced} maintainVisibleContentPosition={maintainVisibleContentPosition} - // 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={10} - windowSize={NATIVE_STREAM_WINDOW_SIZE} - removeClippedSubviews={false} + renderScrollComponent={renderScrollComponent} + recycleItems={false} + alignItemsAtEnd + initialScrollAtEnd + drawDistance={NATIVE_STREAM_DRAW_DISTANCE} + getEstimatedItemSize={getEstimatedItemSize} scrollEnabled={scrollEnabled} showsVerticalScrollIndicator - inverted /> ); } @@ -616,26 +709,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat export function createNativeStreamStrategy(): StreamStrategy { const strategy = createStreamStrategy({ render: (renderInput) => , - orderTailReverse: true, - orderHeadReverse: true, - assistantTurnTraversalStep: 1, - edgeSlot: "header", - historyLiveBoundaryEdge: "first", - liveHeadHistoryBoundaryEdge: "last", - frameChildOrder: "footer-then-content", - flatListInverted: true, - overlayScrollbarInverted: true, - maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION, - bottomAnchorTransportBehavior: { - verificationDelayFrames: 2, - verificationRetryMode: "recheck", - }, + orderTailReverse: false, + orderHeadReverse: false, + assistantTurnTraversalStep: -1, + edgeSlot: "footer", + historyLiveBoundaryEdge: "last", + liveHeadHistoryBoundaryEdge: "first", + frameChildOrder: "content-then-footer", + bottomAnchorTransportBehavior: BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR, disableParentScrollOnInlineDetailsExpansion: false, anchorBottomOnContentSizeChange: false, animateManualScrollToBottom: true, useVirtualizedList: true, - isNearBottom: (input) => input.offsetY <= input.threshold, - getBottomOffset: () => 0, + isNearBottom: (input) => distanceFromEnd(input) <= input.threshold, + getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), }); return strategy; } diff --git a/packages/app/src/agent-stream/strategy-web.browser.test.tsx b/packages/app/src/agent-stream/strategy-web.browser.test.tsx new file mode 100644 index 0000000000..28fbd1e2a2 --- /dev/null +++ b/packages/app/src/agent-stream/strategy-web.browser.test.tsx @@ -0,0 +1,158 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; +import type { StreamItem } from "@/types/stream"; +import type { StreamRenderInput, StreamViewportHandle } from "./strategy"; +import { createWebStreamStrategy } from "./strategy-web"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const VIEWPORT_HEIGHT = 100; +const ROW_HEIGHT = 40; + +// A row's height comes from its text, so a disclosure that opens or grows a row is a new item +// for that row, as it is in the app. +function row(index: number, height = ROW_HEIGHT): Extract { + return { + kind: "user_message", + id: `message-${index}`, + text: String(height), + timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`), + }; +} + +const rowStyleByHeight = new Map(); + +function renderRow(item: StreamItem) { + const height = item.kind === "user_message" ? Number(item.text) : ROW_HEIGHT; + let style = rowStyleByHeight.get(height); + if (!style) { + style = { height }; + rowStyleByHeight.set(height, style); + } + return
{item.id}
; +} + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + host.style.cssText = `display:flex;flex-direction:column;width:500px;height:${VIEWPORT_HEIGHT}px`; + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +function mountTimeline(historyMounted: StreamItem[]) { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted, liveHead: [] }, + boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false }, + renderers: { + renderHistoryVirtualizedRow: renderRow, + renderHistoryMountedRow: renderRow, + renderLiveHeadRow: renderRow, + renderLiveAuxiliary: () => null, + }, + listEmptyComponent: null, + viewportRef, + 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, + }; + const render = (segments: Partial) => + root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } })); + act(() => render({})); + + const scrollContainer = host.querySelector('[data-testid="agent-chat-scroll"]'); + if (!scrollContainer) throw new Error("Expected agent chat scroll container"); + const rowElement = (id: string) => { + const element = host.querySelector(`[data-history-row-id="${id}"]`); + if (!element) throw new Error(`Expected row ${id}`); + return element; + }; + + return { + render: (segments: Partial) => act(() => render(segments)), + hold: (rowId: string, apply: () => void) => + act(() => viewportRef.current!.holdRow(rowId, apply)), + rowTop: (id: string) => + rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + distanceFromEnd: () => + scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop, + // Resize observers and the hold's settle run on animation frames. + settle: () => + act(async () => { + for (let frame = 0; frame < 4; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }), + }; +} + +describe("web disclosure hold", () => { + it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1); + const triggerTop = timeline.rowTop("message-5"); + + timeline.hold("message-5", () => + timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }), + ); + await timeline.settle(); + expect(timeline.rowTop("message-5")).toBe(triggerTop); + + timeline.render({ liveHead: [row(8)] }); + await timeline.settle(); + expect(timeline.rowTop("message-5")).toBe(triggerTop); + }); + + it("keeps the trigger's top when its row grows while following output", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + const triggerTop = timeline.rowTop("message-5"); + + timeline.hold("message-5", () => + timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }), + ); + await timeline.settle(); + expect(timeline.rowTop("message-5")).toBe(triggerTop); + }); + + it("resumes following when collapsing an anchored row lands back at the end", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + timeline.hold("message-5", () => + timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }), + ); + await timeline.settle(); + + timeline.hold("message-5", () => timeline.render({ historyMounted: history })); + await timeline.settle(); + + timeline.render({ liveHead: [row(8)] }); + await timeline.settle(); + expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1); + }); +}); 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 8e8bdf7680..b511ac72dd 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -363,6 +363,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const wasFollowOutputLayoutActiveRef = useRef(false); const resumedUnchangedLayoutRef = useRef(false); const pendingResumeGeometryCheckRef = useRef(false); + // Set by holdRow; the next reading reconcile sees the disclosure's layout. + const disclosurePendingRef = useRef(false); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -394,7 +396,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. @@ -404,10 +406,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 ( @@ -428,7 +434,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) => { @@ -724,25 +730,35 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, }); - const reconcileReadingPosition = useStableEvent(() => { + // Row tops in content coordinates, which survive scrolling. + const measureRows = useStableEvent(() => { const container = scrollContainerRef.current; const content = contentRef.current; - if (!isActiveRef.current || !container || !content) return; + if (!container || !content) return null; const viewportTop = container.getBoundingClientRect().top; const scrollTop = container.scrollTop; const width = content.getBoundingClientRect().width; - const rows = Array.from( - content.querySelectorAll("[data-history-row-id]"), - (element) => { - const bounds = element.getBoundingClientRect(); - const id = element.dataset.historyRowId!; - measuredRowHeights.current.set(id, { width, height: bounds.height }); - return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height }; - }, - ); + return Array.from(content.querySelectorAll("[data-history-row-id]"), (element) => { + const bounds = element.getBoundingClientRect(); + const id = element.dataset.historyRowId!; + measuredRowHeights.current.set(id, { width, height: bounds.height }); + return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height }; + }); + }); + + const reconcileReadingPosition = useStableEvent(() => { + const container = scrollContainerRef.current; + if (!isActiveRef.current || !container) return; + const rows = measureRows(); + if (!rows) return; 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; @@ -754,6 +770,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // this layout so the reader does not get a frame containing only the pin. virtualOffsetListener.current?.(container.scrollTop, false); } + // A disclosure that closes back to the end of the list rejoins the output. + if (disclosurePendingRef.current) { + disclosurePendingRef.current = false; + if (isScrollContainerAtBottom(container)) setFollowOutput(true); + } }); // Apply row positions and their scroll correction in the same paint, including @@ -818,10 +839,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); useEffect(() => { - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); - readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -832,7 +849,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId, readingAnchor]); + }, [evaluateHistoryStart]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -1173,10 +1190,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, - holdReadingPosition: () => { + holdRow: (rowId, apply) => { cancelPendingStickToBottom(); setFollowOutput(false); - readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0); + const row = measureRows()?.find((candidate) => candidate.id === rowId); + if (row) readingAnchor.pin(row); + disclosurePendingRef.current = true; + apply(); }, scrollToMessage, }; @@ -1190,6 +1210,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [ cancelPendingStickToBottom, forceStickToBottom, + measureRows, readingAnchor, scheduleStickToBottom, scrollToMessage, @@ -1370,11 +1391,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { return createStreamStrategy({ render: (renderInput) => ( - + ), orderTailReverse: false, orderHeadReverse: false, @@ -1383,9 +1400,6 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St historyLiveBoundaryEdge: "last", liveHeadHistoryBoundaryEdge: "first", frameChildOrder: "content-then-footer", - flatListInverted: false, - overlayScrollbarInverted: false, - maintainVisibleContentPosition: undefined, bottomAnchorTransportBehavior: { verificationDelayFrames: 0, verificationRetryMode: "rescroll", diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 3d9df4d201..d1d40d351f 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 { @@ -13,11 +14,6 @@ type NeighborRelation = "above" | "below"; type AssistantTurnTraversalStep = -1 | 1; export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content"; -export type MaintainVisibleContentPositionConfig = Readonly<{ - minIndexForVisible: number; - autoscrollToTopThreshold: number; -}>; - export type BottomAnchorTransportBehavior = Readonly<{ verificationDelayFrames: number; verificationRetryMode: "rescroll" | "recheck"; @@ -55,8 +51,11 @@ export interface StreamViewportHandle { 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; + /** + * Run a disclosure's state change so the row that owns its trigger keeps its visual top. + * Following output stops; it resumes if the change leaves the list at its end. + */ + holdRow: (rowId: string, apply: () => void) => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void; } @@ -78,6 +77,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; @@ -122,14 +123,11 @@ export interface StreamStrategy { component: ReactElement | ComponentType | null, gapSize: number, ) => StreamEdgeSlotProps; - getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined; getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null; getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null; getLatestItemIndex: (items: StreamItem[]) => number | null; getFrameChildOrder: () => StreamFrameChildOrder; - getFlatListInverted: () => boolean; - getOverlayScrollbarInverted: () => boolean; shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean; shouldAnchorBottomOnContentSizeChange: () => boolean; shouldAnimateManualScrollToBottom: () => boolean; @@ -145,9 +143,6 @@ interface StreamStrategyConfig { historyLiveBoundaryEdge: "first" | "last"; liveHeadHistoryBoundaryEdge: "first" | "last"; frameChildOrder: StreamFrameChildOrder; - flatListInverted: boolean; - overlayScrollbarInverted: boolean; - maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig; bottomAnchorTransportBehavior: BottomAnchorTransportBehavior; disableParentScrollOnInlineDetailsExpansion: boolean; anchorBottomOnContentSizeChange: boolean; @@ -157,8 +152,6 @@ interface StreamStrategyConfig { getBottomOffset: (metrics: StreamViewportMetrics) => number; } -const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4; - export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy { return { render: config.render, @@ -213,7 +206,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate ListFooterComponentStyle: { marginTop: gapSize }, }; }, - getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition, getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior, getHistoryLiveBoundaryIndex: (history) => { if (history.length === 0) { @@ -234,8 +226,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1; }, getFrameChildOrder: () => config.frameChildOrder, - getFlatListInverted: () => config.flatListInverted, - getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, shouldDisableParentScrollOnInlineDetailsExpansion: () => config.disableParentScrollOnInlineDetailsExpansion, shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange, @@ -244,23 +234,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate }; } -export function resolveBottomAnchorTransportBehavior(input: { - strategy: StreamStrategy; - isViewportSettling: boolean; -}): BottomAnchorTransportBehavior { - const baseBehavior = input.strategy.getBottomAnchorTransportBehavior(); - if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) { - return baseBehavior; - } - return { - verificationDelayFrames: Math.max( - baseBehavior.verificationDelayFrames, - NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES, - ), - verificationRetryMode: "recheck", - }; -} - export function orderTailForStreamRenderStrategy(params: { strategy: StreamStrategy; streamItems: StreamItem[]; diff --git a/packages/app/src/agent-stream/stream-row.browser.test.tsx b/packages/app/src/agent-stream/stream-row.browser.test.tsx new file mode 100644 index 0000000000..7608704d2d --- /dev/null +++ b/packages/app/src/agent-stream/stream-row.browser.test.tsx @@ -0,0 +1,69 @@ +import { act, createRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import type { StreamLayoutItem } from "./layout"; +import type { StreamViewportHandle } from "./strategy"; +import { renderStreamRow } from "./stream-row"; + +// stream-row.tsx is compiled with the React Compiler here, as Expo compiles it. + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +const turnHost: StreamItem = { + kind: "user_message", + id: "host", + text: "Fix the build", + timestamp: new Date(0), +}; + +const turnHostLayout: StreamLayoutItem = { + item: turnHost, + aboveItem: null, + // The turn's rows land below another row, so opening it leaves the host's layout unchanged. + belowItem: null, + gapBelow: 0, + assistantSpacing: "default", + completedFooter: null, + toolSequence: "none", + isFirstInUserGroup: true, + isLastInUserGroup: true, + isLastInToolSequence: false, + frameOrder: "content-then-footer", + phase: "complete", +}; + +it("renders a revised row again when its layout is unchanged", () => { + // The turn's open state lives outside the row, as `getCollapsedTurn` reads it in view.tsx. + let turnOpen = false; + const renderStreamItem = () => {turnOpen ? "open" : "closed"}; + const viewportRef = createRef(); + const layoutItemById = new Map([[turnHost.id, turnHostLayout]]); + const renderRow = (item: StreamItem) => + act(() => + root.render(renderStreamRow({ item, layoutItemById, renderStreamItem, viewportRef })), + ); + + renderRow(turnHost); + expect(host.textContent).toBe("closed"); + + turnOpen = true; + // useRevisedHistoryRows hands the row a fresh item when its display state changes. + renderRow({ ...turnHost }); + + expect(host.textContent).toBe("open"); +}); diff --git a/packages/app/src/agent-stream/stream-row.tsx b/packages/app/src/agent-stream/stream-row.tsx new file mode 100644 index 0000000000..874b032f1d --- /dev/null +++ b/packages/app/src/agent-stream/stream-row.tsx @@ -0,0 +1,58 @@ +import { memo, type ReactNode, type RefObject } from "react"; +import type { StreamItem } from "@/types/stream"; +import { DisclosureRow } from "./disclosure"; +import type { StreamLayoutItem } from "./layout"; +import type { StreamViewportHandle } from "./strategy"; + +/** + * Renders a timeline row from the item the row was handed. The item is a revision of + * `layoutItem.item` with the same content: render from it, so a revision that leaves the layout + * unchanged still renders the row again. + */ +export type RenderStreamItem = (item: StreamItem, layoutItem: StreamLayoutItem) => ReactNode; + +// Live and retained rows use the same boundary so completion preserves the message +// subtree, including measured diagrams and selections. LegendList renders every mounted row +// again when `extraData` (the history renderer) changes, and history rows shift index when older +// history loads. This boundary limits that pass: a row renders again only when its stream item +// identity, its layout item identity, or the renderer itself changes. Item identity is the +// revision signal the strategy already uses (`useRevisedHistoryRows` clones items whose content or +// display state changed). The row passes the item to the renderer, so the React Compiler's cache +// of the rendered content keys on it too. +const StreamRow = memo(function StreamRow({ + item, + layoutItem, + renderStreamItem, + viewportRef, +}: { + item: StreamItem; + layoutItem: StreamLayoutItem; + renderStreamItem: RenderStreamItem; + viewportRef: RefObject; +}) { + return ( + + {renderStreamItem(item, layoutItem)} + + ); +}); + +export function renderStreamRow(input: { + item: StreamItem; + layoutItemById: Map; + renderStreamItem: RenderStreamItem; + viewportRef: RefObject; +}): ReactNode { + const layoutItem = input.layoutItemById.get(input.item.id); + if (!layoutItem) { + return null; + } + return ( + + ); +} 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/top-overlay.ts b/packages/app/src/agent-stream/top-overlay.ts index 24cd7f9592..12652580e2 100644 --- a/packages/app/src/agent-stream/top-overlay.ts +++ b/packages/app/src/agent-stream/top-overlay.ts @@ -1,7 +1,7 @@ import { createContext, useContext } from "react"; import { isNative } from "@/constants/platform"; -/** The native inverted viewport reserves visual-top clearance; web stays in flow. */ +/** The native viewport reserves top clearance under the overlay; web stays in flow. */ export const supportsStreamTopOverlay = isNative; const StreamTopOverlayContext = createContext(0); diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 4535b14d9e..4fab619628 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -12,6 +12,21 @@ function reply(id: string): StreamItem { return { kind: "assistant_message", id, text: id, timestamp: at }; } +function pluginReply(sourceId: string, part = "0"): StreamItem { + return { + kind: "plugin", + id: `math/${sourceId}/${part}`, + sourceId, + sourceKind: "assistant_message", + timestamp: at, + pluginId: "math", + pluginItemId: `${sourceId}/${part}`, + itemKind: "formula", + version: 1, + data: { text: "$$x^2$$" }, + }; +} + function thought(id: string): StreamItem { return { kind: "thought", id, text: id, timestamp: at, status: "ready" }; } @@ -41,6 +56,7 @@ function collapse(tail: StreamItem[], options: Partial = {}) isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, + previousTurnsByHostId: new Map(), ...options, }); } @@ -48,6 +64,118 @@ function collapse(tail: StreamItem[], options: Partial = {}) const ids = (items: StreamItem[]) => items.map((item) => item.id); describe("collapseTurns", () => { + it("keeps every card of the final reply outside the folded turn", () => { + const items = [ + user("prompt"), + reply("acknowledgment"), + shell("call"), + pluginReply("final", "left"), + pluginReply("final", "right"), + ]; + const result = collapse(items); + expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]); + expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt"); + expect(result.hostIdByFoldedMessageId.has("final")).toBe(false); + const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) }); + expect(expanded.tail).toEqual(items); + expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]); + }); + + it("folds intermediate plugin replies even without tool calls", () => { + const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]); + expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]); + expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt"); + }); + + it.each([undefined, "thought", "tool_call"] as const)( + "does not treat a plugin row with source kind %s as a reply", + (sourceKind) => { + const card = { ...pluginReply("card"), sourceKind }; + const items = [user("prompt"), shell("call"), card]; + expect(collapse(items).tail).toEqual(items); + expect(collapse(items).turnsByHostId.size).toBe(0); + }, + ); + + it("keeps a plugin-rendered reply and its work unfolded while the turn is open", () => { + const items = [user("prompt"), shell("call"), pluginReply("final")]; + expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items); + }); + + // A history row renders again only when its item changes, so a prompt that is already retained + // when its turn closes needs its row revised to show the summary line. + it("reports the hosts whose summary line appeared, changed or went away", () => { + const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], { + isLatestTurnOpen: true, + }); + expect([...open.updatedHostIds]).toEqual(["u1"]); + const closed = collapse( + [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")], + { + previousTurnsByHostId: open.turnsByHostId, + }, + ); + expect([...closed.updatedHostIds]).toEqual(["u2"]); + const unchanged = collapse( + [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")], + { + previousTurnsByHostId: closed.turnsByHostId, + }, + ); + expect(unchanged.updatedHostIds.size).toBe(0); + const moreWork = collapse( + [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")], + { previousTurnsByHostId: unchanged.turnsByHostId }, + ); + expect([...moreWork.updatedHostIds]).toEqual(["u2"]); + const rewound = collapse([user("u1"), shell("c1"), reply("r1")], { + previousTurnsByHostId: moreWork.turnsByHostId, + }); + expect([...rewound.updatedHostIds]).toEqual(["u2"]); + }); + 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"), @@ -63,7 +191,6 @@ describe("collapseTurns", () => { hostId: "u1", expanded: false, summary: { - messageCount: 1, tools: expect.objectContaining({ commandCount: 2 }), }, }); @@ -121,6 +248,34 @@ describe("collapseTurns", () => { 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")], { diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index d9f532ad73..0821c74401 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -1,13 +1,18 @@ -import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream"; +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 { - messageCount: number; tools: OverviewSummary; } export interface CollapsedTurn { - /** The user message the turn answers. Its row carries the turn's summary line. */ + /** The input or first loaded row that carries the turn's summary line. */ hostId: string; expanded: boolean; summary: TurnSummary; @@ -20,6 +25,13 @@ export interface TurnCollapse { 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; + /** + * Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A + * history row renders again only when its item changes, so these rows are revised. + */ + updatedHostIds: ReadonlySet; } export interface CollapseTurnsInput { @@ -28,7 +40,9 @@ export interface CollapseTurnsInput { isLatestTurnOpen: boolean; expandedHostIds: ReadonlySet; /** The calls a tool-call group row stands for; undefined for a single call. */ - getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined; + getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined; + /** The previous fold of the same history, the baseline for `updatedHostIds`. */ + previousTurnsByHostId: ReadonlyMap; } const EMPTY_TURNS: ReadonlyMap = new Map(); @@ -39,22 +53,72 @@ function summarizeTurn( items: readonly StreamItem[], getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], ): TurnSummary { - const calls: ToolCallItem[] = []; - let messageCount = 0; + const calls: (ToolCallItem | ThoughtItem)[] = []; for (const item of items) { - if (item.kind === "assistant_message") messageCount += 1; - else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item])); + if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item)) + calls.push(...(getGroupedCalls(item.id) ?? [item])); } - return { messageCount, tools: summarizeToolCalls(calls) }; + return { tools: summarizeToolCalls(calls) }; } function hasWork(summary: TurnSummary): boolean { - return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0); + const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; + return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0); } -function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { +function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean { + const { paseoActions: actionsA, ...countsA } = a.summary.tools; + const { paseoActions: actionsB, ...countsB } = b.summary.tools; + return ( + a.expanded === b.expanded && + hasSameCounts(countsA, countsB) && + hasSameCounts(actionsA, actionsB) + ); +} + +function hasSameCounts(a: Record, b: Record): boolean { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); +} + +function findUpdatedHosts( + previous: ReadonlyMap, + next: ReadonlyMap, +): ReadonlySet { + const updated = new Set(); + for (const [hostId, turn] of next) { + const before = previous.get(hostId); + if (!before || !isSameTurn(before, turn)) updated.add(hostId); + } + for (const hostId of previous.keys()) { + if (!next.has(hostId)) updated.add(hostId); + } + return updated.size === 0 ? EMPTY_IDS : updated; +} + +function staysVisibleWhenCollapsed(item: StreamItem): boolean { + return isTurnTrigger(item) || item.kind === "compaction"; +} + +function isAssistantReply(item: StreamItem): boolean { + return ( + item.kind === "assistant_message" || + (item.kind === "plugin" && item.sourceKind === "assistant_message") + ); +} + +function findLastReplyIndex(tail: StreamItem[], start: number, end: number): number { for (let index = end - 1; index >= start; index -= 1) { - if (tail[index]?.kind === "assistant_message") return index; + const item = tail[index]!; + if (!isAssistantReply(item)) continue; + const messageId = getStreamItemMessageId(item); + // A transformer can split one reply into several cards; keep the whole reply visible. + while (index > start) { + const previous = tail[index - 1]!; + if (!isAssistantReply(previous) || getStreamItemMessageId(previous) !== messageId) break; + index -= 1; + } + return index; } return -1; } @@ -63,7 +127,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): * 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. Thoughts fold with the turn but are not counted. + * before its reply. Reasoning is revealed only when the turn opens. */ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const { tail } = input; @@ -71,52 +135,81 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { 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?.kind !== "user_message" || !isClosed || replyIndex <= runStart) { + const replyIndex = findLastReplyIndex(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)) { + if (!hasWork(summary) && !folded.some(isAssistantReply)) { output.push(...tail.slice(runStart, runEnd)); return; } const expanded = input.expandedHostIds.has(host.id); turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); - for (const item of folded) { + 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); } - if (expanded) output.push(...folded); + 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 (tail[index]?.kind !== "user_message") continue; + 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); + const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId); if (turnsByHostId.size === 0) { return { tail, turnsByHostId: EMPTY_TURNS, nestedItemIds: EMPTY_IDS, hostIdByFoldedMessageId: EMPTY_HOSTS, + hostIdByHiddenRowId: EMPTY_HOSTS, + updatedHostIds, }; } return { - tail: output.length === tail.length ? tail : output, + tail: + output.length === tail.length && output.every((item, index) => item === tail[index]) + ? tail + : output, turnsByHostId, nestedItemIds, hostIdByFoldedMessageId, + hostIdByHiddenRowId, + updatedHostIds, }; } diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 9f73f1ac22..22660b4606 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -1,6 +1,7 @@ -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Summary } from "lucide-react-native"; +import { useDisclosureHold } from "@/agent-stream/disclosure"; import { ExpandableBadge } from "@/components/message"; import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label"; import type { CollapsedTurn } from "./model"; @@ -17,22 +18,11 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({ onExpandedChange: (hostId: string, expanded: boolean) => void; }) { const { t } = useTranslation(); - const { messageCount, tools } = turn.summary; - const messageParts = useMemo( - () => - messageCount > 0 - ? [ - t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, { - count: messageCount, - }), - ] - : [], - [messageCount, t], - ); - const label = useOverviewSummaryLabel(tools, messageParts); + const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); + const hold = useDisclosureHold(); const toggle = useCallback( - () => onExpandedChange(turn.hostId, !turn.expanded), - [onExpandedChange, turn.expanded, turn.hostId], + () => hold(() => onExpandedChange(turn.hostId, !turn.expanded)), + [hold, 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..08fceb4005 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -23,6 +23,10 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; const ThemedSyncedLoader = withUnistyles(SyncedLoader); const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8]; +// Every footer variant (running, completed with Copy, completed with only its duration) gets +// this content height, so a turn ending swaps the footer's controls without moving the rows +// above it. +const TURN_FOOTER_CONTENT_HEIGHT = 24; export type TurnContentStrategy = StreamStrategy; export type AssistantTurnForkHandler = (input: { @@ -197,7 +201,7 @@ function CompletedTurnFooter({ return ( [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []); - return {children}; + return ( + + {children} + + ); } const stylesheet = StyleSheet.create((theme) => ({ @@ -218,18 +226,22 @@ const stylesheet = StyleSheet.create((theme) => ({ alignSelf: "center", paddingHorizontal: theme.spacing[2], }, + // The message above drops its bottom padding for the footer (compactBottom), so this margin + // alone sets the gap. 20 puts the footer as far below the text as the text sits below a + // tool row: 12 message padding + 4 stream gap + 5 badge padding/border, less the footer's + // 1px taller row. turnFooterRow: { - marginTop: theme.spacing[2] + 5, + marginTop: theme.spacing[4] + theme.spacing[1], }, turnFooterSlot: { flexDirection: "row", alignItems: "center", alignSelf: "flex-start", - minHeight: 24, + height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, paddingBottom: TURN_FOOTER_BOTTOM_SPACING, }, turnFooterContent: { - height: 24, + height: TURN_FOOTER_CONTENT_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "flex-start", diff --git a/packages/app/src/agent-stream/turn-membership.test.ts b/packages/app/src/agent-stream/turn-membership.test.ts index 4483e4e1a7..029ebd83f8 100644 --- a/packages/app/src/agent-stream/turn-membership.test.ts +++ b/packages/app/src/agent-stream/turn-membership.test.ts @@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) { isTurnActive, history: model.segments.historyMounted, liveHead: model.segments.liveHead, - timingByAssistantId: model.turnTiming.byAssistantId, + timingByItemId: model.turnTiming.byItemId, }), }; } @@ -67,6 +67,24 @@ function completedFooterIds(layout: ReturnType["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-stream-history-window.test.tsx b/packages/app/src/agent-stream/use-stream-history-window.test.tsx index 2d8f0b4326..1db49ecdfd 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.test.tsx +++ b/packages/app/src/agent-stream/use-stream-history-window.test.tsx @@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => { ]).flat(); const { result } = renderHook(() => - useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }), + useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }), ); expect(result.current.start).toBe(2); @@ -35,7 +35,6 @@ describe("useStreamHistoryWindow", () => { const loadRemoteOlder = vi.fn(async () => true); const { result } = renderHook(() => useStreamHistoryWindow({ - agentId: "agent-1", items: [ message("u1", "user_message"), message("a1", "assistant_message"), @@ -67,8 +66,7 @@ describe("useStreamHistoryWindow", () => { message("a2", "assistant_message"), ]; const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: items } }, ); @@ -91,8 +89,7 @@ describe("useStreamHistoryWindow", () => { message("a2", "assistant_message"), ]; const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: items } }, ); @@ -108,8 +105,7 @@ describe("useStreamHistoryWindow", () => { configurable: true, }); const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: [ @@ -142,7 +138,6 @@ describe("useStreamHistoryWindow", () => { }); const { result } = renderHook(() => useStreamHistoryWindow({ - agentId: "agent-1", items: [ message("u1", "user_message"), message("a1", "assistant_message"), 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 340ecb4da4..cc6d5425d6 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -28,22 +28,17 @@ function findEarlierHistoryWindowStart(input: { } interface HistoryWindowBoundary { - agentId: string; boundaryItemId: string | null; initialized: boolean; } function reconcileHistoryWindow(input: { current: HistoryWindowBoundary; - agentId: string; boundaryIndex: number; initialBoundaryItemId: string | null; hasItems: boolean; }): HistoryWindowBoundary { - const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input; - if (current.agentId !== agentId) { - return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems }; - } + const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input; if (!current.initialized && hasItems) { return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true }; } @@ -54,28 +49,26 @@ function reconcileHistoryWindow(input: { } export function useStreamHistoryWindow(input: { - agentId: string; items: StreamItem[]; loadRemoteOlder: () => boolean | Promise; }) { - const { agentId, items, loadRemoteOlder } = input; + const { items, loadRemoteOlder } = input; const initialStart = useMemo( () => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }), [items], ); const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null); const [window, setWindow] = useState(() => ({ - agentId, boundaryItemId: initialBoundaryItemId, initialized: items.length > 0, })); - const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null; + const boundaryItemId = window.boundaryItemId; const boundaryIndex = useMemo( () => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)), [boundaryItemId, items], ); let start = initialStart; - if (window.agentId === agentId && window.initialized) { + if (window.initialized) { if (window.boundaryItemId === null) { start = 0; } else if (boundaryIndex >= 0) { @@ -87,13 +80,12 @@ export function useStreamHistoryWindow(input: { setWindow((current) => reconcileHistoryWindow({ current, - agentId, boundaryIndex, initialBoundaryItemId, hasItems: items.length > 0, }), ); - }, [agentId, boundaryIndex, initialBoundaryItemId, items.length]); + }, [boundaryIndex, initialBoundaryItemId, items.length]); const revealLoadedHistory = useCallback( (itemId?: string): boolean => { @@ -106,13 +98,12 @@ export function useStreamHistoryWindow(input: { return false; } setWindow({ - agentId, boundaryItemId: items[nextStart]?.id ?? null, initialized: true, }); return true; }, - [agentId, items, start], + [items, start], ); const loadOlder = useCallback(async (): Promise => { return revealLoadedHistory() || (await loadRemoteOlder()); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 67e08a6f82..ab9d3b8258 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -61,6 +61,11 @@ 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 } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; @@ -69,6 +74,8 @@ 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"; +import { renderStreamRow, type RenderStreamItem } from "./stream-row"; +import { type NearBottom, useIsNearBottom, useNearBottom } from "./near-bottom"; import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail"; import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; @@ -90,6 +97,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, @@ -224,41 +232,6 @@ function renderListEmptyComponent(input: { ); } -// Live and retained rows use the same boundary so completion preserves the message -// subtree, including measured diagrams and selections. FlatList recreates history -// cells with fresh refs and shifted indexes on data changes. This boundary is -// what stops that churn: a row renders again only when its stream item identity, its layout item -// identity, or the renderer itself changes. Item identity is the revision signal the strategy -// already uses (`useRevisedHistoryRows` clones items whose content or display state changed). -const StreamRow = memo(function StreamRow({ - layoutItem, - renderStreamItem, -}: { - item: StreamItem; - layoutItem: StreamLayoutItem; - renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; -}) { - return <>{renderStreamItem(layoutItem)}; -}); - -function renderStreamRow(input: { - item: StreamItem; - layoutItemById: Map; - renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; -}): ReactNode { - const layoutItem = input.layoutItemById.get(input.item.id); - if (!layoutItem) { - return null; - } - return ( - - ); -} - export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; @@ -318,6 +291,47 @@ function resolveBottomOverlayControlOffset(clearance: number | undefined): numbe return Math.max(16, clearance ?? 0); } +// Keep entry/exit animations off on Android due to RN dispatchDraw crashes +// tracked in react-native-reanimated#8422. +const shouldDisableEntryExitAnimations = Platform.OS === "android"; +const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(200); +const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(200); + +function ScrollToBottomButton({ + nearBottom, + isTimelineDetached, + bottomOffset, + onPress, +}: { + nearBottom: NearBottom; + isTimelineDetached: boolean; + bottomOffset: number; + onPress: () => void; +}) { + const { t } = useTranslation(); + const isNearBottom = useIsNearBottom(nearBottom); + const containerStyle = useMemo( + () => [stylesheet.scrollToBottomContainer, { bottom: bottomOffset }], + [bottomOffset], + ); + if (isNearBottom && !isTimelineDetached) return null; + return ( + + + + + + + + ); +} + const AgentStreamViewComponent = forwardRef( function AgentStreamView( { @@ -359,13 +373,12 @@ 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) @@ -417,22 +430,6 @@ const AgentStreamViewComponent = forwardRef { - setIsNearBottom(true); - setExpandedInlineToolCallIds(new Set()); - setExpandedToolCallGroupIds(new Set()); - setExpandedTurnHostIds(new Set()); - }, [agentId]); const isActive = useRetainedPanelActive(); const openInlineFile = useStableEvent( @@ -459,20 +456,23 @@ 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(); return () => inlinePathOpener.cancel(); - }, [inlinePathOpener, isActive, agentId]); + }, [inlinePathOpener, isActive]); const handleInlinePathPress = useStableEvent( (target: InlinePathTarget, disposition: OpenFileDisposition) => { if (isActive) void inlinePathOpener.open(target, disposition); @@ -524,8 +524,10 @@ const AgentStreamViewComponent = forwardRef { - // 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); @@ -701,17 +703,7 @@ const AgentStreamViewComponent = forwardRef { - setExpandedToolCallGroupIds((previous) => { - const next = new Set(previous); - if (expanded) { - next.add(groupId); - } else { - next.delete(groupId); - } - return next; - }); - }, []); + const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded; const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { @@ -722,6 +714,7 @@ const AgentStreamViewComponent = forwardRef, isLastInSequence: boolean, - maxDetailHeight?: number, + options: { maxDetailHeight?: number; standalone?: boolean } = {}, ) => { const { payload } = item; @@ -839,7 +832,8 @@ const AgentStreamViewComponent = forwardRef ); } @@ -855,7 +849,8 @@ const AgentStreamViewComponent = forwardRef ); }, @@ -868,12 +863,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, + }) )} )) @@ -896,7 +909,9 @@ const AgentStreamViewComponent = forwardRef { - const item = layoutItem.item; + (item: StreamItem, layoutItem: StreamLayoutItem) => { switch (item.kind) { case "user_message": return renderUserMessageItem(layoutItem, item); @@ -914,10 +928,20 @@ const AgentStreamViewComponent = forwardRef + {renderToolCallItem(layoutItem, item)} + {getCollapsedTurn(item.id) ? ( + + ) : null} + + ); case "notification": return ; @@ -947,17 +971,18 @@ const AgentStreamViewComponent = forwardRef { - const content = renderStreamItemContent(layoutItem); + const renderStreamItem = useCallback( + (item, layoutItem) => { + const content = renderStreamItemContent(item, layoutItem); return renderStreamItemWithTurnFooter({ content, layoutItem, @@ -1026,13 +1051,7 @@ const AgentStreamViewComponent = forwardRef [stylesheet.emptyState, stylesheet.contentWrapper], []); - const scrollToBottomContainerStyle = useMemo( - () => [ - stylesheet.scrollToBottomContainer, - { bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) }, - ], - [bottomOverlayControlClearance], - ); + const scrollToBottomOffset = resolveBottomOverlayControlOffset(bottomOverlayControlClearance); const listEmptyComponent = useMemo( () => renderListEmptyComponent({ @@ -1075,6 +1094,7 @@ const AgentStreamViewComponent = forwardRef(() => { @@ -1130,24 +1151,31 @@ const AgentStreamViewComponent = forwardRef ({ - contentById: presentation.historyGroupUpdatesByHostId, - displayStateById: { + contentById: { has: (id: string) => - expandedToolCallGroupIds.has(id) || + presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id), + }, + 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, + expandedToolCallGroups, + getToolCallGroup, expandedTurnHostIds, isMobile, nestedItemIds, presentation.historyGroupUpdatesByHostId, + updatedTurnHostIds, ], ); @@ -1168,50 +1196,44 @@ 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: nearBottom.set, + onReadingPositionChange: handleReadingPositionChange, + onNearHistoryStart: loadOlder, + isLoadingOlderHistory: isLoadingOlder, + hasOlderHistory: hasOlder, + olderHistoryProgressKey: progressKey, + scrollEnabled: streamScrollEnabled, + listStyle: stylesheet.list, + baseListContentContainerStyle: stylesheet.listContentContainer, + forwardListContentContainerStyle: stylesheet.forwardListContentContainer, + contentMaxWidth, + })} + - {(!isNearBottom || isTimelineDetached) && ( - - - - - - - - )} + @@ -1363,6 +1385,7 @@ interface ThoughtSlotProps { status: Extract["status"]; isLastInSequence: boolean; defaultExpanded: boolean; + standalone?: boolean; } // Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text. @@ -1373,6 +1396,7 @@ function ThoughtSlot({ status, isLastInSequence, defaultExpanded, + standalone, }: ThoughtSlotProps) { const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming"); return ( @@ -1384,6 +1408,7 @@ function ThoughtSlot({ status={status === "ready" ? "completed" : "executing"} isLastInSequence={isLastInSequence} defaultExpanded={defaultExpanded} + standalone={standalone} forceInline={defaultExpanded} /> ); @@ -1398,7 +1423,9 @@ function ToolCallSlot({ (expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded), [onInlineDetailsExpandedChangeByItemId, itemId], ); - return ; + return ( + + ); } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -1743,10 +1770,6 @@ const stylesheet = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, - invertedWrapper: { - transform: [{ scaleY: -1 }], - width: "100%", - }, emptyStateText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, @@ -1861,12 +1884,11 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]); switch (turnIndent) { case "none": - return {children}; case "openedHost": return ( {children} - + {turnIndent === "openedHost" ? : null} ); case "nested": 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 083c6b2144..b4ca122f77 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,3 +1,13 @@ +import { useHardwareKeyboardPresent } from "@/keyboard"; +import { + WindowLayoutProvider, + WindowLayoutActionsHost, + WindowLayoutSurface, + WindowLayoutDivisionGap, + useWindowLayout, + resolveDesktopDivisionLayout, +} from "@/window-layout"; +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"; @@ -15,6 +25,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useState, useSyncExternalStore, @@ -35,6 +46,7 @@ import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { ChangelogHost } from "@/changelog"; +import { OnboardingHost, useIsSetupDecisionPending } from "@/onboarding"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; @@ -43,6 +55,7 @@ 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 { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo"; import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; @@ -56,6 +69,8 @@ import { resolveDesktopAppChromeLayout, resolveDesktopAppContentMinimum, resolveDesktopSidebarVisibility, + resolveSidebarActivity, + resolveLeftSidebarPresentation, } from "@/components/desktop-sidebar-layout"; import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context"; @@ -88,7 +103,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useKeyboardShortcuts } from "@/keyboard/use-shortcuts"; import { KeyboardShiftProvider } from "@/keyboard/shift"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useOpenProject } from "@/hooks/use-open-project"; @@ -109,7 +124,7 @@ import { useHosts, } from "@/runtime/host-runtime"; import { getDaemonStartService } from "@/runtime/daemon-start-service"; -import { usePanelStore } from "@/stores/panel-store"; +import { usePanelStore, type LeftSidebarPresentation } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme"; import { useSessionStore } from "@/stores/session-store"; @@ -137,6 +152,7 @@ export interface HostRuntimeBootstrapState { splashError: string | null; retry: () => void; hasGivenUpWaitingForHost: boolean; + isWaitingForSetup: boolean; storeReady: boolean; startupBlocker: StartupBlocker; } @@ -145,6 +161,7 @@ const HostRuntimeBootstrapContext = createContext({ splashError: null, retry: () => {}, hasGivenUpWaitingForHost: false, + isWaitingForSetup: false, storeReady: false, startupBlocker: { kind: "none" }, }); @@ -241,6 +258,9 @@ function useDaemonStartIsRunning(): boolean { } const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000; +// How long the splash waits on top of host startup to learn whether setup opens. A slow host +// lands on the home instead, and setup opens over it once it is decided. +const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000; async function shouldStartBuiltInDaemon(): Promise { if (!shouldUseDesktopDaemon()) { @@ -302,6 +322,23 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }; }, [shouldRunGiveUpTimer]); + const isSetupDecisionPending = useIsSetupDecisionPending(); + const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false); + const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup; + const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none"; + + useEffect(() => { + if (!shouldRunSetupWaitTimer) { + return; + } + const handle = setTimeout(() => { + setHasGivenUpWaitingForSetup(true); + }, STARTUP_SETUP_WAIT_TIMEOUT_MS); + return () => { + clearTimeout(handle); + }; + }, [shouldRunSetupWaitTimer]); + const retry = useCallback(() => { const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon }); @@ -312,8 +349,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { const storeReady = resolveStartupNavigationReady({ startupBlocker }); const state = useMemo( - () => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }), - [splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker], + () => ({ + splashError, + retry, + hasGivenUpWaitingForHost, + isWaitingForSetup, + storeReady, + startupBlocker, + }), + [splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker], ); return ( @@ -352,9 +396,16 @@ 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: windowWidth } = useWindowDimensions(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); const viewportWidth = windowWidth - insets.left - insets.right; + const divisionLayout = resolveDesktopDivisionLayout({ + geometry: useWindowLayout(), + windowWidth, + windowHeight, + leftInset: insets.left, + rightInset: insets.right, + }); const cycleTheme = useCallback(() => { void updateSettings({ theme: getNextThemePreference(settings.theme) }); @@ -415,12 +466,24 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon isCompactLayout, isMounted: desktopSidebarMounted, isOpen: isDesktopAgentListOpen, + divisionLayout, canShare: canDesktopAppSidebarShare({ contentMinimumWidth: appContentMinimumWidth, requestedSidebarWidth: sidebarWidth, viewportWidth, }), }); + const sidebarPresentation = resolveLeftSidebarPresentation({ + chromeEnabled, + isCompactLayout, + isFocusModeEnabled: isWorkspaceFocusModeEnabled, + desktopSidebarVisible, + }); + const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout); + // Publishes the layout to the store, which owns the panel targets each layout allows. + useLayoutEffect(() => { + applyPanelLayout({ isCompact: isCompactLayout, leftSidebar: sidebarPresentation }); + }, [applyPanelLayout, isCompactLayout, sidebarPresentation]); const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); const appChromeLayout = resolveDesktopAppChromeLayout({ desktopSidebarRendered: desktopSidebarVisible, @@ -430,7 +493,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const sidebarChrome = ( ); @@ -438,18 +501,19 @@ 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} + {children} - + ); // Native panel gesture hosts outlive appearance keys, like native navigators. @@ -465,6 +529,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const surface = ( {workspaceChrome} + {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( @@ -493,6 +558,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + @@ -504,11 +570,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + {/* Setup changes the theme while it is open, so it stays out of the remount above. Its + colors are CSS variables and themed props, which update in place, and setup changes no + font size. */} + ); return ( - + {surface} ); @@ -516,15 +586,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon function AppContainerLifetime({ children, - compactGesturesEnabled, + sidebarGesturesEnabled, }: { children: ReactNode; - compactGesturesEnabled: boolean; + sidebarGesturesEnabled: boolean; }) { return ( - + {children} @@ -534,20 +604,26 @@ function AppContainerLifetime({ function SidebarChrome({ mounted, - visible, + presentation, keyboardShortcutsEnabled, }: { mounted: boolean; - visible: boolean; + presentation: LeftSidebarPresentation; keyboardShortcutsEnabled: boolean; }) { const isCompactLayout = useIsCompactFormFactor(); const isMobileActive = useIsMobilePanelActive("agent-list"); - const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen); - const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen); + const overlay = presentation === "overlay"; + const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive); + const hardwareKeyboardPresent = useHardwareKeyboardPresent(); return ( - - {mounted ? : null} + + {mounted ? ( + + ) : null} ); @@ -560,8 +636,8 @@ function MobileGestureWrapper({ children: ReactNode; chromeEnabled: boolean; }) { - const openGesture = useOpenAgentListGesture(chromeEnabled); const isCompactLayout = useIsCompactFormFactor(); + const openGesture = useOpenAgentListGesture(chromeEnabled); return ( @@ -805,7 +881,6 @@ function RootStack() { - @@ -867,20 +942,31 @@ function RuntimeProviders({ children }: { children: ReactNode }) { // auth, settings, ...) must wrap PortalProvider, not be wrapped by it. // BottomSheetModalProvider is the exception: Gorhom modals consume portal // context and need one shared provider for sibling sheets to stack. +function RootOverlayProviders({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + + ); +} + function RootProviders({ children }: { children: ReactNode }) { return ( - - - - - - {children} - - - - - + + + + + {children} + + + + ); } @@ -890,11 +976,13 @@ function RootAppTree() { - - - - - + + + + + + + diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index e0649f65a1..f6850a2a57 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -45,11 +45,19 @@ export default function Index() { }), isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, + isWaitingForSetup: bootstrapState.isWaitingForSetup, }); + const splash = ; if (startupRoute.kind === "redirect") { - return ; + // The loading screen stays up until the target route paints; a redirect alone renders nothing. + return ( + <> + {splash} + + + ); } - return ; + return splash; } 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/appearance/theme-picker.tsx b/packages/app/src/appearance/theme-picker.tsx new file mode 100644 index 0000000000..cb2ebf5983 --- /dev/null +++ b/packages/app/src/appearance/theme-picker.tsx @@ -0,0 +1,292 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import type { PluginThemeOption } from "@/plugins/themes"; +import { + THEME_OPTIONS, + PLUGIN_THEME_PREFERENCE, + darkTheme, + lightTheme, + type Theme, +} from "@/styles/theme"; +import { useContributedThemes } from "./provider"; + +type BuiltInThemePreference = Exclude; + +// System leads the grid because it is the default. +const PICKER_THEME_OPTIONS = [ + ...THEME_OPTIONS.filter((option) => option.name === "auto"), + ...THEME_OPTIONS.filter((option) => option.name !== "auto"), +]; + +const PREVIEW_ASPECT_RATIO = 16 / 9; + +/** + * Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies + * the theme to the whole app at once. Settings → Appearance and first-run setup share it. + */ +export function ThemePicker() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const { options: pluginThemes, selected: selectedPluginTheme, select } = useContributedThemes(); + // A selected contribution that is no longer installed shows the fallback the app renders. + const builtInValue = + settings.theme === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : settings.theme; + + const selectBuiltIn = useCallback( + (theme: BuiltInThemePreference) => { + void updateSettings({ theme }); + }, + [updateSettings], + ); + + return ( + + {PICKER_THEME_OPTIONS.map((option) => ( + + ))} + {pluginThemes.map((option) => ( + + ))} + + ); +} + +function BuiltInThemeTile({ + value, + selected, + onSelect, +}: { + value: BuiltInThemePreference; + selected: boolean; + onSelect: (value: BuiltInThemePreference) => void; +}) { + const { t } = useTranslation(); + const handlePress = useCallback(() => onSelect(value), [onSelect, value]); + const option = THEME_OPTIONS.find((candidate) => candidate.name === value); + return ( + + ); +} + +function PluginThemeTile({ + option, + selected, + onSelect, +}: { + option: PluginThemeOption; + selected: boolean; + onSelect: (option: PluginThemeOption) => void; +}) { + const handlePress = useCallback(() => onSelect(option), [onSelect, option]); + return ( + + ); +} + +function ThemeTile({ + label, + selected, + onPress, + previewTheme, +}: { + label: string; + selected: boolean; + onPress: () => void; + /** "system" follows the OS, so its preview shows both halves. */ + previewTheme: Theme | "system"; +}) { + const accessibilityState = useMemo(() => ({ checked: selected }), [selected]); + const tileStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.tile, + hovered && !selected && styles.tileHovered, + selected && styles.tileSelected, + ], + [selected], + ); + return ( + + + + {previewTheme === "system" ? ( + + ) : ( + + )} + + + {label} + + + + ); +} + +/** A sketch of the app — sidebar, a few lines of text and an accent bubble — in `theme`'s colors. */ +function ThemePreview({ theme }: { theme: Theme }) { + const colors = theme.colors; + const previewStyles = useMemo( + () => ({ + root: [styles.preview, { backgroundColor: colors.surface0 }], + sidebar: [styles.previewSidebar, { backgroundColor: colors.surfaceSidebar }], + mutedLine: { backgroundColor: colors.foregroundMuted }, + strongLine: { backgroundColor: colors.foreground }, + bubble: [styles.previewBubble, { backgroundColor: colors.accent }], + }), + [colors], + ); + return ( + + + + + + + + + + + + + + ); +} + +/** "System" follows the OS: light on the left half, dark on the right. */ +function SystemThemePreview() { + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Columns fill the row: each cell is a fixed fraction of it, and the half-gap padding on every + // cell plus the negative margin here leaves exactly one gap between neighbors. + grid: { + flexDirection: "row", + flexWrap: "wrap", + margin: -theme.spacing[3] / 2, + }, + cell: { + width: { xs: "50%", md: "25%" }, + padding: theme.spacing[3] / 2, + }, + tile: { + padding: theme.spacing[1], + gap: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[2], + borderColor: "transparent", + }, + tileHovered: { + backgroundColor: theme.colors.surface2, + }, + tileSelected: { + borderColor: theme.colors.accent, + }, + previewFrame: { + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + label: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingHorizontal: theme.spacing[1], + }, + preview: { + width: "100%", + aspectRatio: PREVIEW_ASPECT_RATIO, + flexDirection: "row", + overflow: "hidden", + }, + previewSidebar: { + width: 36, + paddingHorizontal: 6, + paddingVertical: 8, + gap: 5, + }, + previewMain: { + flex: 1, + padding: 8, + gap: 5, + }, + previewLine: { + height: 4, + borderRadius: 2, + opacity: 0.6, + }, + previewTitle: { + width: "55%", + height: 5, + borderRadius: 2, + }, + sidebarLineWide: { width: "100%" }, + sidebarLineNarrow: { width: "60%" }, + sidebarLineMedium: { width: "80%" }, + mainLineWide: { width: "90%" }, + mainLineNarrow: { width: "65%" }, + previewBubble: { + marginTop: "auto", + alignSelf: "flex-end", + width: "45%", + height: 10, + borderRadius: 5, + }, + systemDarkHalf: { + position: "absolute", + top: 0, + bottom: 0, + left: "50%", + width: "50%", + overflow: "hidden", + }, + // A full-width dark preview shifted left by half, so only its right half shows. + systemDarkOffset: { + width: "200%", + marginLeft: "-100%", + }, +})); 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/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 121c60f5a0..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, @@ -424,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 66c4137e2d..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; @@ -105,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, @@ -215,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/background-work/host.test.ts b/packages/app/src/background-work/host.test.ts new file mode 100644 index 0000000000..9142f56df4 --- /dev/null +++ b/packages/app/src/background-work/host.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "vitest"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { createHostBackgroundWorkModel } from "./host"; + +test("retries a failed observation and receives subsequent updates", async () => { + interface Observer { + snapshot(): void; + update(message: SessionOutboundMessage): void; + error(error: unknown): void; + } + const observers: Observer[] = []; + let releases = 0; + const item = { + id: "shell", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01T00:00:00Z", + }; + const client = { + getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }), + stopAgentBackgroundWork: async () => {}, + listAgentBackgroundWork: async () => [item], + observeAgentBackgroundWork: () => ({ + subscribe: (observer: Observer) => { + observers.push(observer); + }, + release: async () => { + releases++; + }, + }), + }; + const model = createHostBackgroundWorkModel("host", "agent", { + getClient: () => client, + subscribe: () => () => {}, + }); + const unsubscribe = model.subscribe(() => {}); + observers[0].snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[0].error(new Error("subscription failed")); + expect(model.getSnapshot()).toEqual({ status: "error", items: [item] }); + model.retry(); + expect(observers).toHaveLength(2); + expect(releases).toBe(1); + observers[1].snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[0].error(new Error("late error")); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[1].update({ + type: "agent.background_work.update", + payload: { agentId: "agent", items: [] }, + }); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("starts observing after the same client reconnects", async () => { + let connected = false; + let notify = () => {}; + let snapshot = () => {}; + const item = { + id: "shell", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01T00:00:00Z", + }; + const client = { + getLastServerInfoMessage: () => + connected ? { features: { agentBackgroundWork: true } } : null, + stopAgentBackgroundWork: async () => {}, + listAgentBackgroundWork: async () => [item], + observeAgentBackgroundWork: () => ({ + subscribe: (observer: { snapshot(): void }) => { + snapshot = observer.snapshot; + }, + release: async () => {}, + }), + }; + const model = createHostBackgroundWorkModel("host", "agent", { + getClient: () => client, + subscribe: (listener) => { + notify = listener; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + connected = true; + notify(); + snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + unsubscribe(); +}); diff --git a/packages/app/src/background-work/host.ts b/packages/app/src/background-work/host.ts new file mode 100644 index 0000000000..00134f18f0 --- /dev/null +++ b/packages/app/src/background-work/host.ts @@ -0,0 +1,115 @@ +import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { createBackgroundWorkModel } from "./model"; + +interface BackgroundWorkClient { + getLastServerInfoMessage(): { + features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null; + } | null; + stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise; + listAgentBackgroundWork(agentId: string): Promise; + observeAgentBackgroundWork(): { + subscribe(observer: { + snapshot(): void; + update(message: SessionOutboundMessage): void; + error(error: unknown): void; + }): unknown; + release(): Promise; + }; +} + +interface BackgroundWorkHost { + getClient(): BackgroundWorkClient | null; + subscribe(listener: () => void): () => void; +} + +function hostFor(serverId: string): BackgroundWorkHost { + const host = getHostRuntimeStore(); + return { + getClient: () => host.getSnapshot(serverId)?.client ?? null, + subscribe: (listener) => host.subscribe(serverId, listener), + }; +} + +export function createHostBackgroundWorkModel( + serverId: string, + agentId: string, + host: BackgroundWorkHost = hostFor(serverId), +) { + let client: BackgroundWorkClient | null = null; + const controls = (items: PaseoAgentBackgroundWorkItem[]) => { + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true) + return items; + return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item); + }; + return createBackgroundWorkModel({ + stop: (item) => { + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.stopAgentBackgroundWork(agentId, item); + }, + list: () => { + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.listAgentBackgroundWork(agentId).then(controls); + }, + observe: ({ snapshot, update, disconnected, failed }) => { + let release: (() => void) | undefined; + let currentObservation: + | ReturnType + | undefined; + const sync = () => { + const candidate = host.getClient(); + // The runtime keeps the client across reconnects, but clears server info until handshake. + const next = candidate?.getLastServerInfoMessage() ? candidate : null; + if (next === client) return; + const previousRelease = release; + client = next; + release = undefined; + disconnected(); + previousRelease?.(); + if (!client) return; + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) { + failed(); + return; + } + const observedClient = client; + const observation = client.observeAgentBackgroundWork(); + currentObservation = observation; + observation.subscribe({ + snapshot: () => { + if (client === observedClient && currentObservation === observation) snapshot(); + }, + update: (message) => { + if ( + client === observedClient && + currentObservation === observation && + message.type === "agent.background_work.update" && + message.payload.agentId === agentId + ) + update(controls(message.payload.items)); + }, + error: () => { + if (client === observedClient && currentObservation === observation) failed(); + }, + }); + release = () => { + if (currentObservation === observation) currentObservation = undefined; + void observation + .release() + .catch((error: unknown) => + console.error("Background work subscription cleanup failed", error), + ); + }; + }; + const unsubscribe = host.subscribe(sync); + sync(); + return () => { + unsubscribe(); + client = null; + release?.(); + }; + }, + }); +} diff --git a/packages/app/src/background-work/index.tsx b/packages/app/src/background-work/index.tsx new file mode 100644 index 0000000000..34dfbb83ff --- /dev/null +++ b/packages/app/src/background-work/index.tsx @@ -0,0 +1,155 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; +import { LiveElapsed } from "@/components/message"; +import { Button } from "@/components/ui/button"; +import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client"; +import { createHostBackgroundWorkModel } from "./host"; +import { backgroundWorkText } from "./presentation"; +export { createHostBackgroundWorkModel } from "./host"; + +function kindKey(kind: string): "shell" | "monitor" | "other" { + if (kind === "shell" || kind === "monitor") return kind; + return "other"; +} + +export function BackgroundWorkTrack({ + model, + count, +}: { + model: ReturnType; + count: number; +}) { + const { t } = useTranslation(); + const { items, status, stops } = useSyncExternalStore( + model.subscribe, + model.getSnapshot, + model.getSnapshot, + ); + const missing = Object.values(stops ?? {}).filter( + (entry) => !items?.some((item) => item.stopToken === entry.item.stopToken), + ); + const rows = [...(items ?? []), ...missing.map((entry) => entry.item)]; + if (count === 0 && rows.length === 0) return null; + const visibleCount = items?.length || count; + return ( + + {status === "loading" || status === "stale" ? ( + + {t("message.backgroundWork.waiting")} + + ) : null} + {status === "error" ? ( + <> + + {t("message.backgroundWork.error")} + + + {t("message.backgroundWork.retry")} + + + ) : null} + {rows.map((item) => ( + entry.stopToken === item.stopToken))} + ready={status === "ready"} + stopStatus={stops?.[item.stopToken ?? ""]?.status} + /> + ))} + + ); +} + +function BackgroundWorkRow({ + item, + model, + live, + ready, + stopStatus, +}: { + item: PaseoAgentBackgroundWorkItem; + model: ReturnType; + live: boolean; + ready: boolean; + stopStatus?: "pending" | "error"; +}) { + const { t } = useTranslation(); + const kind = t(`message.backgroundWork.${kindKey(item.kind)}`); + const stop = useCallback(() => { + void model.stopItem(item); + }, [model, item]); + const dismiss = useCallback( + () => model.dismissStop(item.stopToken ?? ""), + [model, item.stopToken], + ); + let detail = t("message.backgroundWork.notRunning"); + if (stopStatus === "pending") detail = t("message.backgroundWork.stopping"); + const { title, command } = backgroundWorkText(item, kind); + const action = item.canStop && ready && live; + const label = + stopStatus === "pending" + ? t("message.backgroundWork.stopping") + : t("message.backgroundWork.stop"); + return ( + + + {title} + {command ? {command} : null} + + {kind} ·{" "} + {live ? ( + + ) : ( + detail + )} + + {stopStatus === "error" ? ( + {t("message.backgroundWork.stopError")} + ) : null} + + {action ? ( + + ) : null} + {!live && stopStatus === "error" ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 }, + title: { fontSize: theme.fontSize.base, color: theme.colors.foreground }, + facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, +})); diff --git a/packages/app/src/background-work/model.test.ts b/packages/app/src/background-work/model.test.ts new file mode 100644 index 0000000000..46fcaf8e95 --- /dev/null +++ b/packages/app/src/background-work/model.test.ts @@ -0,0 +1,233 @@ +import { expect, test } from "vitest"; +import { createBackgroundWorkModel } from "./model"; + +test("keeps a newer update when an earlier list resolves after it", async () => { + let resolve!: ( + value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + let update!: ( + items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + update = callbacks.update; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + expect(model.getSnapshot().items).toBeNull(); + snapshot(); + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + update([item]); + resolve([]); + await Promise.resolve(); + expect(model.getSnapshot().items).toEqual([item]); + unsubscribe(); +}); + +test("retains the last inventory while reconnecting and replaces it after resync", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + const reads: Array<(value: (typeof item)[]) => void> = []; + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: () => new Promise((resolve) => reads.push(resolve)), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + reads[0]([item]); + await Promise.resolve(); + snapshot(); + expect(model.getSnapshot().items).toEqual([item]); + reads[1]([]); + await Promise.resolve(); + expect(model.getSnapshot().items).toEqual([]); + unsubscribe(); +}); + +test("ignores a list response from a connection that was replaced", async () => { + let snapshot!: () => void; + let disconnected!: () => void; + let resolve!: ( + items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + disconnected = callbacks.disconnected; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + disconnected(); + resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]); + await Promise.resolve(); + expect(model.getSnapshot().items).toBeNull(); + unsubscribe(); +}); + +test("retains stale items on failure and retries the inventory", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + let snapshot!: () => void; + let resolve!: (items: (typeof item)[]) => void; + let reject!: (error: Error) => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done, fail) => { + resolve = done; + reject = fail; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + resolve([item]); + await Promise.resolve(); + snapshot(); + expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] }); + reject(new Error("offline")); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "error", items: [item] }); + model.retry(); + expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] }); + resolve([]); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("reports an initial read failure without claiming an empty inventory", async () => { + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: async () => { + throw new Error("offline"); + }, + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "error", items: null }); + unsubscribe(); +}); + +test("recreates a failed observation on retry and resumes live updates", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + let observation!: Parameters[0]["observe"]>[0]; + let starts = 0; + let releases = 0; + const model = createBackgroundWorkModel({ + list: async () => [item], + observe: (callbacks) => { + observation = callbacks; + starts++; + return () => { + releases++; + }; + }, + }); + const unsubscribe = model.subscribe(() => {}); + observation.failed(); + expect(model.getSnapshot()).toEqual({ status: "error", items: null }); + model.retry(); + expect(starts).toBe(2); + expect(releases).toBe(1); + observation.snapshot(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observation.update([]); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => { + const item = { + id: "1", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01", + canStop: true, + stopToken: "generation-1", + }; + let update!: (items: (typeof item)[]) => void; + let reject!: (error: Error) => void; + const model = createBackgroundWorkModel({ + list: async () => [item], + stop: () => + new Promise((_, fail) => { + reject = fail; + }), + observe: (callbacks) => { + update = callbacks.update; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + update([item]); + const pending = model.stopItem(item); + update([]); + expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item }); + reject(new Error("connection lost")); + await pending; + expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item }); + model.dismissStop("generation-1"); + expect(model.getSnapshot().stops).toEqual({}); + unsubscribe(); +}); + +test("ignores a stop response from a disconnected generation", async () => { + const first = { + id: "1", + kind: "shell", + description: "old", + startedAt: "2026-01-01", + canStop: true, + stopToken: "first", + }; + const replacement = { ...first, description: "new", stopToken: "second" }; + let update!: (items: (typeof first)[]) => void; + let disconnect!: () => void; + let resolve!: () => void; + const model = createBackgroundWorkModel({ + list: async () => [], + stop: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + update = callbacks.update; + disconnect = callbacks.disconnected; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + update([first]); + const pending = model.stopItem(first); + disconnect(); + update([replacement]); + resolve(); + await pending; + expect(model.getSnapshot().items).toEqual([replacement]); + expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first }); + unsubscribe(); +}); diff --git a/packages/app/src/background-work/model.ts b/packages/app/src/background-work/model.ts new file mode 100644 index 0000000000..311abc92e7 --- /dev/null +++ b/packages/app/src/background-work/model.ts @@ -0,0 +1,121 @@ +import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client"; + +interface Port { + list(): Promise; + stop?(item: AgentBackgroundWorkItem): Promise; + observe(callbacks: { + snapshot(): void; + disconnected(): void; + failed(): void; + update(items: AgentBackgroundWorkItem[]): void; + }): () => void; +} + +interface BackgroundWorkState { + status: "loading" | "stale" | "ready" | "error"; + items: AgentBackgroundWorkItem[] | null; + stops?: Record; +} + +export function createBackgroundWorkModel(port: Port) { + let state: BackgroundWorkState = { status: "loading", items: null }; + let sequence = 0; + let stopGeneration = 0; + let stop: (() => void) | undefined; + let observationFailed = false; + const listeners = new Set<() => void>(); + const publish = (next: BackgroundWorkState) => { + state = + next.stops === undefined && state.stops === undefined + ? next + : { ...next, stops: next.stops ?? state.stops }; + for (const listener of listeners) listener(); + }; + const refresh = async () => { + const request = ++sequence; + publish({ status: state.items === null ? "loading" : "stale", items: state.items }); + try { + const items = await port.list(); + if (request === sequence) publish({ status: "ready", items }); + } catch { + if (request === sequence) publish({ status: "error", items: state.items }); + } + }; + const start = () => { + observationFailed = false; + stop = port.observe({ + snapshot: () => { + void refresh(); + }, + disconnected: () => { + sequence++; + stopGeneration++; + const stops = + state.stops && + Object.fromEntries( + Object.entries(state.stops).map(([id, entry]) => [ + id, + { ...entry, status: "error" as const }, + ]), + ); + publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops }); + }, + failed: () => { + observationFailed = true; + sequence++; + publish({ status: "error", items: state.items }); + }, + update: (items) => { + sequence++; + publish({ status: "ready", items }); + }, + }); + }; + return { + getSnapshot: () => state, + dismissStop: (token: string) => { + const stops = { ...state.stops }; + delete stops[token]; + publish({ ...state, stops }); + }, + stopItem: async (item: AgentBackgroundWorkItem) => { + const token = item.stopToken; + if (!port.stop || !token || state.stops?.[token]?.status === "pending") return; + if (!state.items?.some((entry) => entry.stopToken === token)) return; + const generation = stopGeneration; + publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } }); + try { + await port.stop(item); + if (generation !== stopGeneration) return; + const stops = { ...state.stops }; + delete stops[token]; + publish({ ...state, stops }); + } catch { + if (generation === stopGeneration) + publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } }); + } + }, + retry: () => { + if (observationFailed) { + stop?.(); + stop = undefined; + publish({ status: state.items === null ? "loading" : "stale", items: state.items }); + start(); + } else { + void refresh(); + } + }, + subscribe(listener: () => void) { + listeners.add(listener); + if (!stop) start(); + return () => { + listeners.delete(listener); + if (!listeners.size) { + sequence++; + stop?.(); + stop = undefined; + } + }; + }, + }; +} diff --git a/packages/app/src/background-work/presentation.test.ts b/packages/app/src/background-work/presentation.test.ts new file mode 100644 index 0000000000..738dde76ca --- /dev/null +++ b/packages/app/src/background-work/presentation.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { backgroundWorkText } from "./presentation"; + +test("shows descriptions and commands once, with useful fallbacks", () => { + expect( + backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"), + ).toEqual({ title: "Watch build", command: "tail -f build.log" }); + expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({ + title: "sleep 10", + command: null, + }); + expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({ + title: "sleep 10", + command: null, + }); + expect(backgroundWorkText({ description: null }, "Shell")).toEqual({ + title: "Shell", + command: null, + }); +}); diff --git a/packages/app/src/background-work/presentation.ts b/packages/app/src/background-work/presentation.ts new file mode 100644 index 0000000000..4913289501 --- /dev/null +++ b/packages/app/src/background-work/presentation.ts @@ -0,0 +1,7 @@ +export function backgroundWorkText( + item: { description: string | null; command?: string }, + kind: string, +): { title: string; command: string | null } { + const title = item.description ?? item.command ?? kind; + return { title, command: item.command && item.command !== title ? item.command : null }; +} diff --git a/packages/app/src/changelog/internal/changelog-sheet.tsx b/packages/app/src/changelog/internal/changelog-sheet.tsx index 04afdcafb0..7447b96225 100644 --- a/packages/app/src/changelog/internal/changelog-sheet.tsx +++ b/packages/app/src/changelog/internal/changelog-sheet.tsx @@ -118,7 +118,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB description={t("changelog.error.description")} testID="changelog-error" > - diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 7a44f8ccd9..1c20f0725d 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,8 +1,9 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; +import { Modal } from "@/components/ui/modal"; import equal from "fast-deep-equal"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { FlatList, - Modal, Pressable, Text, View, @@ -52,11 +53,7 @@ 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, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore, @@ -763,6 +760,10 @@ function SectionRow({ row }: { row: Extract state.key(key), - [state], - ); const submit = useCallback(() => state.key("Enter"), [state]); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (!state.key(event.key)) return false; event.preventDefault(); return true; }, [state], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && state.open && !showBottomSheet, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + active: state.open, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); if (showBottomSheet) { @@ -911,45 +909,46 @@ export function CommandCenter() { keyboardBlurBehavior="restore" accessible={false} > - - {state.scope === "files" ? ( - - ) : null} - - - - {fileSearchError} - + + + {state.scope === "files" ? ( + + ) : null} + + + + {fileSearchError} + + ); } if (!state.open) return null; return ( - + - + {state.scope === "files" ? ( diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index bb39882630..9978aed6e1 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -1,3 +1,4 @@ +import { usesDesktopBindings } from "@/keyboard"; import { useMemo } from "react"; import { router, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; @@ -15,12 +16,12 @@ import { Settings, } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; -import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useImportSession } from "@/hooks/use-import-session"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { usePanelStore } from "@/stores/panel-store"; @@ -111,7 +112,7 @@ export function CommandCenterRootActions() { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const { overrides } = useKeyboardShortcutOverrides(); - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); const openAddProject = useOpenAddProject(); const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const settingsRoute = useMemo(() => buildSettingsRoute(), []); @@ -128,7 +129,7 @@ export function CommandCenterRootActions() { const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList); const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList; const shortcutPlatform = useMemo( - () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), + () => ({ isMac: getShortcutOs() === "mac", usesDesktopBindings }), [], ); const actions = useMemo(() => { diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index cd9d008c55..41416d30eb 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -1,3 +1,4 @@ +import { usesDesktopBindings } from "@/keyboard"; import { useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { @@ -105,7 +106,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde } function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts { - const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() }; + const platform = { isMac: getShortcutOs() === "mac", usesDesktopBindings }; return resolveWorkspaceCommandCenterShortcuts({ overrides, platform }); } diff --git a/packages/app/src/command-center/workspace-shortcuts.test.ts b/packages/app/src/command-center/workspace-shortcuts.test.ts index bfffce9577..bb8d66c983 100644 --- a/packages/app/src/command-center/workspace-shortcuts.test.ts +++ b/packages/app/src/command-center/workspace-shortcuts.test.ts @@ -6,7 +6,7 @@ describe("resolveWorkspaceCommandCenterShortcuts", () => { expect( resolveWorkspaceCommandCenterShortcuts({ overrides: {}, - platform: { isMac: true, isDesktop: true }, + platform: { isMac: true, usesDesktopBindings: true }, }).newAgent, ).toEqual([["mod", "shift", "A"]]); }); diff --git a/packages/app/src/command-center/workspace-shortcuts.ts b/packages/app/src/command-center/workspace-shortcuts.ts index c51a1fcc60..98be2986cd 100644 --- a/packages/app/src/command-center/workspace-shortcuts.ts +++ b/packages/app/src/command-center/workspace-shortcuts.ts @@ -6,7 +6,7 @@ import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions" interface ResolveWorkspaceCommandCenterShortcutsInput { overrides: ShortcutOverrides; - platform: { isMac: boolean; isDesktop: boolean }; + platform: { isMac: boolean; usesDesktopBindings: boolean }; } export function resolveWorkspaceCommandCenterShortcuts({ 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 f487f07f07..7e28f3d81a 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,18 +1,19 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { useCallback, useEffect, useMemo, useState } from "react"; 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 { getOverlayRoot, OverlayLayerProvider, useGlobalOverlayLayer } from "../lib/overlay-root"; import { - getOverlayRoot, - OverlayLayerProvider, - useGlobalWebOverlayLayer, - useWebOverlayRegistration, -} from "../lib/overlay-root"; -import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; + 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 +26,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 +41,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; @@ -91,7 +95,7 @@ const styles = StyleSheet.create((theme) => ({ maxHeight: "85%", flexShrink: 1, minHeight: 0, - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface0, borderRadius: theme.borderRadius.xl, borderWidth: 1, borderColor: theme.colors.surface2, @@ -132,6 +136,12 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE], borderRadius: theme.borderRadius.lg, }, + // Without a header the close button floats over the card's top-right corner. + floatingClose: { + position: "absolute", + top: theme.spacing[4], + right: theme.spacing[4], + }, searchRow: { flexDirection: "row", alignItems: "center", @@ -222,6 +232,28 @@ const styles = StyleSheet.create((theme) => ({ })); const WEB_EXIT_DURATION_MS = 160; +const WEB_ENTER_DURATION_MS = 160; +const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter"; + +// The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed +// while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens +// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced +// motion the keyframes are never defined, so the card appears at once. +function ensureWebEnterKeyframes() { + if (!isWeb || typeof document === "undefined") return; + if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return; + const style = document.createElement("style"); + style.id = WEB_ENTER_ANIMATION_NAME; + style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`; + document.head.appendChild(style); +} +ensureWebEnterKeyframes(); +// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts. +const WEB_ENTER_STYLE = { + animationName: WEB_ENTER_ANIMATION_NAME, + animationDuration: `${WEB_ENTER_DURATION_MS}ms`, + animationTimingFunction: "ease-out", +} as unknown as ViewStyle; /** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ export function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -244,32 +276,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} + + ); } @@ -462,8 +517,27 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) { ); } +/** + * The sheet's action row: a top border, the sheet's side inset, and children spread across it. + * The sheet draws it for `footer`; a body that splits into panes renders it at the bottom of one. + */ +export function SheetFooter({ + style, + children, +}: { + style?: StyleProp; + children: ReactNode; +}) { + return {children}; +} + export interface AdaptiveModalSheetProps { - header: SheetHeader; + /** + * The title row with the close button. Leave it out when the body draws its own chrome, such as + * a full-height side rail: the desktop card then floats the close button over its top-right + * corner, and the compact sheet closes from its handle. + */ + header?: SheetHeader; visible: boolean; onClose: () => void; onDismiss?: () => void; @@ -488,6 +562,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 ( + + {header ? : null} + + {scrollable ? ( + + + {children} + + + ) : ( + + + {children} + + + )} + + + ); +} + export function AdaptiveModalSheet({ header, visible, @@ -508,34 +652,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) { @@ -552,7 +673,7 @@ export function AdaptiveModalSheet({ }); const [shouldRenderWeb, setShouldRenderWeb] = useState(visible); const [isWebClosing, setIsWebClosing] = useState(false); - const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb); + const modalLayer = useGlobalOverlayLayer("modal", visible || shouldRenderWeb); const handleDismiss = useCallback(() => { handleSheetDismiss(); onDismiss?.(); @@ -563,6 +684,7 @@ export function AdaptiveModalSheet({ styles.desktopCard, desktopHeight != null && { height: desktopHeight }, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }, + isWeb && WEB_ENTER_STYLE, ], [desktopMaxWidth, desktopHeight], ); @@ -580,8 +702,8 @@ export function AdaptiveModalSheet({ [isWebClosing, modalLayer], ); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (event.key !== "Escape") return false; event.preventDefault(); event.stopPropagation(); @@ -590,10 +712,11 @@ export function AdaptiveModalSheet({ }, [onClose], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && !isMobile && visible, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], + active: visible, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); useEffect(() => { @@ -614,32 +737,6 @@ export function AdaptiveModalSheet({ }, [visible, isMobile, onDismiss, shouldRenderWeb]); if (isMobile) { - const sheetContent = ( - <> - - - {scrollable ? ( - - - {children} - - - ) : ( - - - {children} - - - )} - - - ); - return ( - {sheetContent} + + + {children} + + ); } @@ -667,7 +777,7 @@ export function AdaptiveModalSheet({ desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent; const cardInner = ( - + {header ? : null} {scrollable ? ( {footerView} + {header ? null : ( + + + + )} ); @@ -693,13 +812,7 @@ export function AdaptiveModalSheet({ style={ABSOLUTE_FILL_STYLE} onPress={onClose} /> - + {cardInner} diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index a1cb5cf779..038e026c76 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,6 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; +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"; @@ -22,14 +25,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, @@ -74,19 +70,15 @@ import { } from "@/components/project-picker-options"; import { Shortcut } from "@/components/ui/shortcut"; import { useRevealActiveItem } from "@/components/ui/scroll-reveal"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { getIsElectronRuntime } from "@/constants/layout"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { pickDirectory } from "@/desktop/pick-directory"; import { useFetchQuery } from "@/data/query"; import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project"; -import { - OverlayLayerProvider, - useGlobalWebOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root"; import { useHosts, useHostRuntimeClient, @@ -98,7 +90,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; @@ -307,7 +299,7 @@ function FlowRow({ } function FlowHint({ keys, action }: { keys: string[]; action: string }) { - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); if (!shortcutsAvailable) return null; return ( @@ -331,6 +323,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); @@ -464,13 +457,10 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { }); const handleBack = useCallback(() => { - setState((current) => { - const previous = backAddProjectPage(current); - if (previous) return previous; - onClose(); - return current; - }); - }, [onClose]); + const previous = backAddProjectPage(state); + if (previous) setState(previous); + else onClose(); + }, [onClose, state]); const openNewWorkspaceForProject = useCallback( (serverId: string, project: WorkspaceProjectDescriptorPayload) => { @@ -622,9 +612,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(); - usePanelStore.getState().showMobileAgent(); - router.push(buildSettingsAddHostRoute(Date.now())); + addHost(); }, }); } @@ -723,6 +713,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { directoryPaths, githubQuery.data, host, + addHost, onClose, openAddedProject, page, @@ -818,30 +809,22 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { [activeIndex, handleBack, rows, submitActive], ); - const modalLayer = useGlobalWebOverlayLayer("modal", isWeb); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const modalLayer = useGlobalOverlayLayer("modal", true); + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (!handleKey(event.key)) return false; event.preventDefault(); return true; }, [handleKey], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + active: true, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); - const handleNativeKeyPress = useCallback( - ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { - if (key === "ArrowDown" || key === "ArrowUp" || key === "Escape") { - handleKey(key); - } - }, - [handleKey], - ); - const handleInputChange = useCallback((value: string) => { setState((current) => currentAddProjectPage(current).kind === "new-directory-name" @@ -876,7 +859,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { - {page.kind === "method" && isNative ? ( - // Native hardware-keyboard events need a focused responder even without a visible field. - - ) : null} {page.kind !== "method" ? ( ); - return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal); + return createElement(OverlayLayerProvider, { layer: modalLayer }, modal); } const styles = StyleSheet.create((theme) => ({ @@ -1073,12 +1038,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], outlineStyle: "none", } as object, - keyboardCapture: { - position: "absolute", - width: 1, - height: 1, - opacity: 0, - }, results: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, resultsContent: { paddingVertical: theme.spacing[2] }, row: { diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index b487e53660..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, diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index b5f6a3ea54..8930bf44c3 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -41,7 +41,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout {t("agentPanel.archived.callout")} ) : null} @@ -1692,7 +1694,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/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx index d80fa2bad0..2da515b5c8 100644 --- a/packages/app/src/components/project-edit-sheet.tsx +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -9,9 +9,8 @@ import type { ProjectIconSource } from "@getpaseo/protocol/messages"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { ProjectIconView } from "@/components/project-icon-view"; import { Button } from "@/components/ui/button"; -import type { FieldControlSize } from "@/components/ui/control-geometry"; +import type { ControlSize } from "@/components/ui/control-geometry"; import { Field, FormTextInput } from "@/components/ui/form-field"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useFilePicker } from "@/hooks/use-file-picker"; import { @@ -50,7 +49,7 @@ export function ProjectEditSheet({ const queryClient = useQueryClient(); const toast = useToast(); const { pickFiles } = useFilePicker(); - const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; + const size: ControlSize = "md"; const [form] = useState(() => openProjectEditForm(snapshot)); const state = useSyncExternalStore(form.subscribe, form.getState, form.getState); @@ -96,7 +95,6 @@ export function ProjectEditSheet({ - @@ -436,18 +436,11 @@ function renderProviderSheetFooter({ ) : null} - + ) : null} + + ); + } + + return ( + + {status.kind === "loading" ? ( + + + + ) : null} + + {status.message} + + ); } @@ -121,16 +206,11 @@ export function Autocomplete({ options, selectedIndex, onSelect, - isLoading = false, - errorMessage, - loadingText, - emptyText, + status, maxHeight = 220, }: AutocompleteProps) { const { t } = useTranslation(); - const { theme } = useUnistyles(); - const resolvedLoadingText = loadingText ?? t("common.states.loading"); - const resolvedEmptyText = emptyText ?? t("common.empty.noResults"); + const isCompact = useIsCompactFormFactor(); const scrollRef = useRef(null); const rowLayoutsRef = useRef>(new Map()); const viewportHeightRef = useRef(0); @@ -210,117 +290,51 @@ export function Autocomplete({ [ensureActiveItemVisible], ); - const selectedOption = options[selectedIndex]; const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]); - if (isLoading) { - return ( - - - {resolvedLoadingText} - - - ); - } + const noResultsMessage = t("common.empty.noResults"); + const shownStatus = useMemo( + () => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null), + [noResultsMessage, options.length, status], + ); - if (errorMessage) { + if (shownStatus) { return ( - - Error: {errorMessage} - - - ); - } - - if (options.length === 0) { - return ( - - - {resolvedEmptyText} - + ); } return ( - - {selectedOption?.kind === "command" && selectedOption.description ? ( - - - {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - - - {removeBoltGlyphs(selectedOption.description)} - - {selectedOption.detail ? ( - - {removeBoltGlyphs(selectedOption.detail)} - - ) : null} - - ) : null} - - - {options.map((option, index) => ( - - ))} - - + + + {options.map((option, index) => ( + + ))} + ); } const styles = StyleSheet.create((theme: Theme) => ({ - outerWrapper: { - flexShrink: 1, - minHeight: 0, - gap: theme.spacing[1], - }, - detailCard: { - flexShrink: 1, - minHeight: 0, - overflow: "hidden", - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - ...theme.shadow.md, - }, - detailLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - detailDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - detailHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, container: { flexShrink: 1, // Keep a complete selectable row (36), list padding (8), and borders (2). @@ -342,60 +356,63 @@ const styles = StyleSheet.create((theme: Theme) => ({ item: { flexDirection: "row", alignItems: "center", + gap: theme.spacing[2], minHeight: 36, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, - itemLeading: { - width: 18, - alignItems: "center", - justifyContent: "center", - marginRight: theme.spacing[1], - }, itemActive: { backgroundColor: theme.colors.surface2, }, - itemMain: { - flex: 1, - minWidth: 0, - }, - itemMainRow: { - flex: 1, - minWidth: 0, - flexDirection: "row", + itemIcon: { + width: 16, alignItems: "center", - gap: theme.spacing[2], - }, - itemHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], + justifyContent: "center", }, itemLabel: { + flexShrink: 0, + maxWidth: "60%", color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - itemDetail: { + itemArgumentHint: { + flexShrink: 0, + maxWidth: "30%", color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.sm, }, itemDescription: { + flex: 1, + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - marginTop: 2, }, - itemDescriptionInline: { + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 44, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + statusText: { + flex: 1, + minWidth: 0, + gap: 2, + }, + statusTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + statusReason: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + statusMessage: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - emptyItem: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, })) as unknown as Record; diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index abcaf240d6..55e4808b8e 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -20,14 +20,13 @@ import type { } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { - buttonIconSize, + controlIconSize, createControlGeometry, - type ButtonControlSize, + type ControlSize, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive"; -type ButtonSize = ButtonControlSize; type LeftIcon = | ReactElement @@ -102,15 +101,11 @@ const styles = StyleSheet.create((theme) => { md: { ...geometry.buttonMd, }, - xs: { - ...geometry.buttonXs, - }, sm: { ...geometry.buttonSm, }, - lg: { - ...geometry.buttonLg, - }, + iconOnlySm: geometry.buttonIconOnlySm, + iconOnlyMd: geometry.buttonIconOnlyMd, default: { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, @@ -139,11 +134,11 @@ const styles = StyleSheet.create((theme) => { }, text: { color: theme.colors.foreground, - ...geometry.buttonText, - fontWeight: theme.fontWeight.normal, + ...geometry.buttonTextMd, + fontWeight: theme.fontWeight.medium, }, - textXs: { - ...geometry.buttonTextXs, + textSm: { + ...geometry.buttonTextSm, }, textDefault: { color: theme.colors.accentForeground, @@ -168,6 +163,7 @@ export function Button({ trailing, style, textStyle, + numberOfLines, disabled, loading = false, accessibilityRole, @@ -177,11 +173,12 @@ export function Button({ Omit & { ref?: React.Ref; variant?: ButtonVariant; - size?: ButtonSize; + size?: ControlSize; leftIcon?: LeftIcon; trailing?: ReactNode; style?: StyleProp; textStyle?: StyleProp; + numberOfLines?: number; loading?: boolean; } >) { @@ -203,16 +200,9 @@ export function Button({ variantStyle = styles.destructive; } - let sizeStyle: ViewStyle; - if (size === "xs") { - sizeStyle = styles.xs; - } else if (size === "sm") { - sizeStyle = styles.sm; - } else if (size === "lg") { - sizeStyle = styles.lg; - } else { - sizeStyle = styles.md; - } + const isIconOnly = children == null && leftIcon != null; + const sizeStyle = size === "sm" ? styles.sm : styles.md; + const iconOnlyStyle = size === "sm" ? styles.iconOnlySm : styles.iconOnlyMd; const isGhostHovered = hovered && variant === "ghost"; const handleHoverIn = useCallback(() => setHovered(true), []); @@ -222,18 +212,19 @@ export function Button({ ({ pressed }: PressableStateCallbackType): StyleProp => [ styles.base, sizeStyle, + isIconOnly ? iconOnlyStyle : null, variantStyle, pressed ? styles.pressed : null, isDisabled ? styles.disabled : null, style, ], - [sizeStyle, variantStyle, isDisabled, style], + [sizeStyle, isIconOnly, iconOnlyStyle, variantStyle, isDisabled, style], ); const resolvedTextStyle = useMemo( () => [ styles.text, - size === "xs" ? styles.textXs : null, + size === "sm" ? styles.textSm : null, variant === "default" ? styles.textDefault : null, variant === "destructive" ? styles.textDestructive : null, variant === "ghost" ? styles.textGhost : null, @@ -274,10 +265,14 @@ export function Button({ - {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 84d76c1083..cee4fc2dea 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,3 +1,5 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, @@ -12,7 +14,6 @@ import { View, Text, Pressable, - Modal, ScrollView, Platform, StatusBar, @@ -62,12 +63,9 @@ import { } from "@/components/adaptive-modal-sheet"; import { FloatingSurface } from "@/components/ui/floating"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { - getOverlayRoot, - OverlayLayerProvider, - useOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; +import { density, densityIconBox } from "@/styles/density"; +import { DensityIcon } from "@/components/ui/density-icon"; import { buildDesktopFrameStyle } from "./combobox-frame-style"; export { buildDesktopFrameStyle } from "./combobox-frame-style"; @@ -207,7 +205,7 @@ export function SearchInput({ return ( - + {leadingSlot}; } else if (kind === "directory") { leadingContent = ( - + ); } else if (kind === "file") { leadingContent = ( - + ); } @@ -312,7 +308,7 @@ export function ComboboxItem({ {selected || trailingSlot ? ( - {selected ? : null} + {selected ? : null} {trailingSlot} @@ -526,7 +522,6 @@ type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape"; interface DesktopKeyHandlerInput { isOpen: boolean; - isMobile: boolean; orderedVisibleOptions: ComboboxOption[]; activeIndex: number; setActiveIndex: React.Dispatch>; @@ -871,11 +866,12 @@ function isDesktopKey(key: string): key is DesktopKey { function dispatchDesktopKey( input: DesktopKeyHandlerInput, key: DesktopKey, - event?: KeyboardEvent, + event?: KeyboardInputEvent, ): boolean { if (!input.isOpen) return false; - if (!IS_WEB && input.isMobile) return false; - + // A native mobile bail used to live here because native had no key source, so + // nothing could call this. It has one now, and the sheet highlights an active + // row like the popover does, so a hardware keyboard drives both. if (key === "ArrowDown" || key === "ArrowUp") { event?.preventDefault(); handleDesktopArrowKey(input, key); @@ -912,6 +908,7 @@ function resolveInitialActiveIndex( } interface MobileBodyProps { + overlayLayer: number; isOpen: boolean; snapPoints: string[]; handleIndicatorStyle: { backgroundColor: string }; @@ -938,7 +935,6 @@ interface MobileBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; children: ReactNode; - safeAreaBottom: number; } function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { @@ -961,21 +957,6 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { () => [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 ) : ( @@ -1006,8 +987,8 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - + + {props.header ? ( ) : ( @@ -1045,17 +1026,43 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { {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; handleClose: () => void; - handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean; + setOverlayScope: (node: unknown) => void; + handleDesktopKey: (key: DesktopKey, event?: KeyboardInputEvent) => boolean; refs: ReturnType["refs"]; shouldUseDesktopFade: boolean; desktopFrameStyle: StyleProp; @@ -1182,25 +1189,13 @@ function DesktopComboboxOptionsBody(props: { function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null { const active = useRetainedPanelActive(); - const handleDesktopKey = props.handleDesktopKey; - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { - if (!isDesktopKey(event.key)) return false; - return handleDesktopKey(event.key, event); - }, - [handleDesktopKey], - ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && props.isOpen, - layer: props.overlayLayer, - onKeyDown: handleWebOverlayKeyDown, - }); + const setOverlayScope = props.setOverlayScope; const setFloatingRef = useCallback( (node: View | null) => { props.refs.setFloating(node); - setWebOverlayScope(node); + setOverlayScope(node); }, - [props.refs, setWebOverlayScope], + [props.refs, setOverlayScope], ); // Remove the portal before a retained chat can freeze its closing update. @@ -1327,7 +1322,6 @@ export function Combobox({ const isMobile = useIsCompactFormFactor(); const active = useRetainedPanelActive(); const floatingLayer = useOverlayLayer("floating"); - const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition); @@ -1525,11 +1519,10 @@ export function Combobox({ ); const handleDesktopKey = useCallback( - (key: DesktopKey, event?: KeyboardEvent) => { + (key: DesktopKey, event?: KeyboardInputEvent) => { return dispatchDesktopKey( { isOpen, - isMobile, orderedVisibleOptions, activeIndex, setActiveIndex, @@ -1540,7 +1533,7 @@ export function Combobox({ event, ); }, - [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions], + [activeIndex, handleClose, handleSelect, isOpen, orderedVisibleOptions], ); useDismissKeyboardOnOpen(isOpen, isMobile); @@ -1577,12 +1570,20 @@ export function Combobox({ [], ); + const setOverlayScope = useOverlayRegistration({ + active: isOpen, + layer: floatingLayer, + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + onKeyDown: (event) => (isDesktopKey(event.key) ? handleDesktopKey(event.key, event) : false), + }); + const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder; const hasChildren = Boolean(children); if (isMobile) { return ( {children} @@ -1623,6 +1623,7 @@ export function Combobox({ isOpen={isOpen} handleClose={handleClose} handleDesktopKey={handleDesktopKey} + setOverlayScope={setOverlayScope} refs={refs} shouldUseDesktopFade={shouldUseDesktopFade} desktopFrameStyle={desktopFrameStyle} @@ -1681,7 +1682,7 @@ const styles = StyleSheet.create((theme) => ({ comboboxItem: { flexDirection: "row", alignItems: "center", - minHeight: 36, + minHeight: density.rowHeight, gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -1712,7 +1713,7 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.55, }, comboboxItemTrailingSlot: { - width: 16, + width: densityIconBox("md"), alignItems: "center", justifyContent: "center", }, @@ -1732,7 +1733,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, comboboxItemLeadingSlot: { - width: 16, + width: densityIconBox("md"), alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 3544d9f940..431bb0434b 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -1,9 +1,31 @@ import { describe, expect, it } from "vitest"; import { - buttonControlHeight, - buttonIconSize, + ALERT_ICON_SIZE, + bySizing, + CONTROL_HEIGHTS, + controlHeightForLayout, + controlSizingForLayout, createControlGeometry, + HEADER_CONTROL_HEIGHT, + ICON_BUTTON_SIZES, + iconSizeFor, + resolveControlSizing, + switchGeometryForLayout, getControlInteractionPhase, + TOOLBAR_CONTROL_GAP, + TOOLBAR_CONTROL_GAPS, + TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_GLYPH_SIZES, + TOOLBAR_DISC_SIZE, + TOOLBAR_DISC_SIZES, + TOOLBAR_GLYPH_SIZES, + TOOLBAR_RINGS, + toolbarGlyphColor, + toolbarGlyphsFor, + toolbarGlyphTone, + toolbarSlot, + TOUCH_TARGET, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; @@ -26,6 +48,7 @@ const theme = { xs: 10, sm: 12, base: 14, + lg: 16, }, opacity: { 50: 0.5, @@ -36,12 +59,121 @@ const theme = { 1: 4, 2: 8, 3: 12, + 3.5: 14, 4: 16, 6: 24, + 7: 28, 8: 32, }, } as unknown as Theme; +describe("control sizing gate", () => { + it("is touch-sized in the compact layout or on a touch screen, and pointer-sized otherwise", () => { + expect(resolveControlSizing({ compactLayout: true, touchScreen: true })).toBe("touch"); + expect(resolveControlSizing({ compactLayout: true, touchScreen: false })).toBe("touch"); + expect(resolveControlSizing({ compactLayout: false, touchScreen: true })).toBe("touch"); + expect(resolveControlSizing({ compactLayout: false, touchScreen: false })).toBe("pointer"); + }); + + it("gives a touch screen its touch sizes at every width", () => { + const sizes = { touch: 44, pointer: 32 }; + + // An iPad: touch-sized in the docked desktop layout too. + expect(bySizing(sizes, true)).toEqual({ xs: 44, md: 44 }); + // A mouse-driven window: touch-sized only once it narrows into the compact layout. + expect(bySizing(sizes, false)).toEqual({ xs: 44, md: 32 }); + }); + + it("keeps a device without a touch screen on pointer sizes from md up", () => { + // The unit runtime has no touch screen, which is the desktop that must not change. + expect(controlSizingForLayout(false)).toBe("pointer"); + expect(controlSizingForLayout(true)).toBe("touch"); + expect(CONTROL_HEIGHTS.md).toEqual({ xs: 44, md: 32 }); + expect(CONTROL_HEIGHTS.sm).toEqual({ xs: 32, md: 28 }); + expect(HEADER_CONTROL_HEIGHT).toEqual({ xs: 32, md: 26 }); + }); + + it("follows the HIG on touch: a 44pt target, no control under 28pt, larger glyphs", () => { + expect(TOUCH_TARGET).toBe(44); + expect(controlHeightForLayout("md", true)).toBe(TOUCH_TARGET); + expect(ICON_BUTTON_SIZES.large.touch).toBe(TOUCH_TARGET); + expect(ICON_BUTTON_SIZES.small.touch).toBe(TOUCH_TARGET); + // Composer toolbar controls: a full target on touch, unchanged 28 on pointer. + expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 }); + expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 }); + // A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its + // 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph. + expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer }); + expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 }); + expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 }); + for (const size of ["sm", "md"] as const) { + expect(controlHeightForLayout(size, true)).toBeGreaterThan( + controlHeightForLayout(size, false), + ); + expect(controlHeightForLayout(size, false)).toBeGreaterThanOrEqual(28); + } + for (const size of ["xs", "sm", "md", "lg"] as const) { + expect(iconSizeFor(size).touch).toBeGreaterThan(iconSizeFor(size).pointer); + } + }); +}); + +describe("toolbar glyphs and spacing", () => { + it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => { + expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 }); + expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 }); + // A 20pt glyph's disc-sized frame reaches 8 into the gap on each side. + expect(toolbarSlot(20)).toEqual({ + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + marginHorizontal: { xs: -8, md: 0 }, + }); + }); + + it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => { + const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; + expect(toolbarGlyphsFor("touch").icon).toEqual({ + size: 20, + strokeWidth: 1.5, + tone: "foreground", + }); + expect(toolbarGlyphsFor("pointer")).toEqual({ icon: pointerGlyph, label: pointerGlyph }); + expect(TOOLBAR_GLYPH_SIZES.icon).toEqual({ xs: 20, md: 16 }); + expect(toolbarGlyphsFor("touch").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.touch); + expect(toolbarGlyphsFor("pointer").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.pointer); + // A disc's solid glyph stays at the `md` size so the stop square does not fill its disc. + expect(TOOLBAR_DISC_GLYPH_SIZES).toEqual({ touch: 20, pointer: 16 }); + }); + + it("keeps a touch glyph beside a label at the label's 20pt scale", () => { + expect(toolbarGlyphsFor("touch").label).toEqual({ + size: 20, + strokeWidth: 1.5, + tone: "foreground", + }); + expect(TOOLBAR_GLYPH_SIZES.label).toEqual({ xs: 20, md: 16 }); + }); + + it("sizes the touch context ring to the toolbar glyph's ink and keeps the pointer ring", () => { + expect(TOOLBAR_RINGS).toEqual({ + touch: { size: 18, strokeWidth: 2 }, + pointer: { size: 16, strokeWidth: 2 }, + }); + }); + + it("brightens a muted toolbar glyph under a hovering pointer", () => { + const pointer = toolbarGlyphsFor("pointer").icon; + const touch = toolbarGlyphsFor("touch").icon; + expect(toolbarGlyphTone(pointer, false)).toBe("muted"); + expect(toolbarGlyphTone(pointer, true)).toBe("foreground"); + expect(toolbarGlyphTone(touch, false)).toBe("foreground"); + const colors = { foreground: "#fafafa", foregroundMuted: "#a1a5a4" }; + const palette = { colors } as unknown as Theme; + expect(toolbarGlyphColor(palette, "foreground")).toBe("#fafafa"); + expect(toolbarGlyphColor(palette, "muted")).toBe("#a1a5a4"); + }); +}); + describe("control geometry", () => { it("keeps resting control borders transparent while preserving border geometry", () => { const geometry = createControlGeometry(theme); @@ -81,95 +213,106 @@ 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.fieldTextSm.fontSize).toBe(12); + expect(geometry.fieldTextSm.lineHeight).toBe(17); expect(geometry.fieldTextMd.fontSize).toBe(14); expect(geometry.fieldTextMd.lineHeight).toBe(20); - expect(geometry.formTextInputSm.fontSize).toBe(14); - expect(geometry.formTextInputSm.lineHeight).toBe(20); + expect(geometry.formTextInputSm.fontSize).toBe(12); expect(geometry.formTextInputMd.fontSize).toBe(14); - expect(geometry.formTextInputMd.lineHeight).toBe(20); }); it("derives field padding from content and border without changing the control height", () => { 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( - geometry.fieldTextSm.lineHeight + - geometry.fieldControlSm.paddingVertical * 2 + - borderWidth * 2, - ).toBe(geometry.fieldControlSm.minHeight); - - expect(geometry.fieldControlMd.minHeight).toBe(44); - expect(geometry.fieldControlMd.paddingVertical).toBe(11); - expect( - geometry.fieldTextMd.lineHeight + - geometry.fieldControlMd.paddingVertical * 2 + - borderWidth * 2, - ).toBe(geometry.fieldControlMd.minHeight); - - expect(geometry.formTextInputSm.paddingVertical).toBe(5); - expect(geometry.formTextInputMd.paddingVertical).toBe(11); + expect(geometry.fieldControlSm.minHeight).toEqual({ xs: 32, md: 28 }); + // The default keeps a 44 touch target in the compact layout and is 32 from the md + // breakpoint up. + expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 }); + expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 }); + for (const layout of ["xs", "md"] as const) { + expect( + geometry.fieldTextSm.lineHeight + + geometry.fieldControlSm.paddingVertical[layout] * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlSm.minHeight[layout]); + expect( + geometry.fieldTextMd.lineHeight + + geometry.fieldControlMd.paddingVertical[layout] * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlMd.minHeight[layout]); + } }); it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => { const geometry = createControlGeometry(theme); - expect(geometry.segmentedContainerXs.padding).toBe(0); expect(geometry.segmentedContainerSm.padding).toBe(0); expect(geometry.segmentedContainerMd.padding).toBe(0); - expect(geometry.segmentedSegmentXs.borderRadius).toBe(geometry.buttonXs.borderRadius); expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius); expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius); - expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight); - expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight); - expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight); - expect(geometry.segmentedSegmentXs.minHeight).toBe(24); - expect(geometry.segmentedSegmentSm.minHeight).toBe(28); - expect(geometry.segmentedSegmentMd.minHeight).toBe(38); + expect(geometry.segmentedContainerSm.minHeight).toEqual(geometry.buttonSm.minHeight); + expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight); + expect(geometry.segmentedSegmentSm.minHeight).toEqual({ xs: 28, md: 24 }); + expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 }); }); - it("keeps one size contract across buttons and segmented controls", () => { + it("keeps one size contract across buttons, fields, and segmented controls", () => { const geometry = createControlGeometry(theme); - // xs is a genuinely smaller tier, not sm with a different font. - expect(geometry.buttonXs.minHeight).toBe(28); - expect(geometry.buttonSm.minHeight).toBe(32); - expect(geometry.buttonMd.minHeight).toBe(44); - expect(geometry.buttonXs.minHeight).toBe(buttonControlHeight.xs); - expect(geometry.buttonSm.minHeight).toBe(buttonControlHeight.sm); - expect(geometry.buttonMd.minHeight).toBe(buttonControlHeight.md); + expect(geometry.buttonSm.minHeight).toEqual({ xs: 32, md: 28 }); + expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 }); + expect(geometry.buttonSm.minHeight).toEqual(CONTROL_HEIGHTS.sm); + expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md); + expect(geometry.fieldControlSm.minHeight).toEqual(CONTROL_HEIGHTS.sm); + expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md); + expect(controlHeightForLayout("md", true)).toBe(44); + expect(controlHeightForLayout("md", false)).toBe(32); + expect(controlHeightForLayout("sm", true)).toBe(32); + expect(controlHeightForLayout("sm", false)).toBe(28); // Same size name means the same label size on every control kind. - expect(geometry.segmentedLabelXs.fontSize).toBe(12); - expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize); - expect(geometry.segmentedLabelSm.fontSize).toBe(14); - expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize); - expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize); + expect(geometry.segmentedLabelSm.fontSize).toBe(12); + expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonTextSm.fontSize); + expect(geometry.segmentedLabelMd.fontSize).toBe(14); + expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonTextMd.fontSize); - // Segments sit inside a track, so they run one padding step tighter than a - // standalone button of the same size — the gap between segments reads as the padding. - expect(geometry.segmentedSegmentXs.paddingHorizontal).toBeLessThan( - geometry.buttonXs.paddingHorizontal, - ); + // Segments sit inside a track, so they run tighter than a standalone button of the same + // size — the gap between segments reads as the padding. expect(geometry.segmentedSegmentSm.paddingHorizontal).toBeLessThan( geometry.buttonSm.paddingHorizontal, ); - expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan( - geometry.buttonMd.paddingHorizontal, - ); + for (const layout of ["xs", "md"] as const) { + expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan( + geometry.buttonMd.paddingHorizontal[layout], + ); + } + }); + + it("sizes switches with the same two control sizes", () => { + expect(switchGeometryForLayout("md", false)).toEqual({ + trackWidth: 34, + trackHeight: 20, + thumbSize: 16, + thumbTravel: 14, + reservedHeight: 32, + }); + expect(switchGeometryForLayout("md", true)).toEqual({ + trackWidth: 44, + trackHeight: 26, + thumbSize: 22, + thumbTravel: 18, + reservedHeight: 44, + }); + // Tight chrome keeps the pointer-sized track on every layout and reserves its row's height. + expect(switchGeometryForLayout("sm", true)).toMatchObject({ + trackWidth: 34, + reservedHeight: 32, + }); + expect(switchGeometryForLayout("sm", false)).toMatchObject({ + trackWidth: 34, + reservedHeight: 28, + }); }); it("gives alert title and description one font size and a tight gap per size", () => { @@ -188,7 +331,7 @@ describe("control geometry", () => { const { alert } = createControlGeometry(theme); for (const size of ["xs", "sm", "md", "lg"] as const) { - expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]); + expect(alert[size].iconSlot.width).toBe(ALERT_ICON_SIZE[size]); expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap); } }); diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index 0a41f80039..0ec75ad6ab 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -1,9 +1,22 @@ import type { StyleProp, ViewStyle } from "react-native"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { ICON_SIZE, ICON_STROKE_WIDTH, type Theme } from "@/styles/theme"; +import { + bySizing, + controlSizingForLayout, + mapLayout, + TOUCH_TARGET, + type ControlSizing, + type LayoutValue, + type SizingValue, +} from "./control-sizing"; -export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; -export type FieldControlSize = "xs" | "sm" | "md"; -export type SegmentedControlSize = "xs" | "sm" | "md"; +/** + * Controls come in two sizes. `md` is the default and the right size almost everywhere; + * `sm` is for tight chrome such as toolbars and nested rows. + */ +export type ControlSize = "sm" | "md"; +/** Alerts are containers, not controls, so they keep their own density scale. */ +export type AlertSize = "xs" | "sm" | "md" | "lg"; export type ControlInteractionPhase = "rest" | "hover" | "active"; export interface ControlInteractionState { @@ -22,58 +35,238 @@ export interface ControlInteractionStyleMap { controlDisabled?: StyleProp; } -const TIGHT_CONTROL_HEIGHT = 28; -const COMPACT_CONTROL_HEIGHT = 32; -const FIELD_CONTROL_HEIGHT = 44; -export const HEADER_CONTROL_HEIGHT = 26; -const SEGMENTED_TIGHT_INSET = 2; -const SEGMENTED_COMPACT_INSET = 2; -const SEGMENTED_FIELD_INSET = 3; -const SWITCH_TRACK_WIDTH = 34; -const SWITCH_TRACK_HEIGHT = 20; -const SWITCH_THUMB_SIZE = 16; +export { + bySizing, + controlSizingForLayout, + mapLayout, + resolveControlSizing, + TOUCH_TARGET, + type ControlSizing, + type ControlSizingFacts, + type LayoutValue, + type SizingValue, +} from "./control-sizing"; + +// Touch `md` is the iOS HIG's 44pt default control; touch `sm` stays above its 28pt minimum. +const CONTROL_SIZES = { + sm: { touch: 32, pointer: 28 }, + md: { touch: TOUCH_TARGET, pointer: 32 }, +} satisfies Record; + +/** Pointer-sized header controls run tighter than `sm` so they fit the 36pt header. */ +const POINTER_HEADER_CONTROL_HEIGHT = 26; +const SEGMENTED_INSET = 2; const CONTROL_FOCUS_RING_WIDTH = 2; const CONTROL_FOCUS_RING_OFFSET = 1; -const CONTROL_CENTER_JUSTIFY_CONTENT = "center"; const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4; /** - * The three control heights every button, field, and segmented control is built from. - * Exported so a row that hosts one of those controls can size itself from the same - * numbers instead of guessing a height the control then outgrows. + * The height of every button, field, and segmented control of a size. Exported so a row that + * hosts one of those controls can size itself from the same numbers instead of guessing a + * height the control then outgrows. Breakpoint values, so they only work inside a Unistyles + * stylesheet. */ export const CONTROL_HEIGHTS = { - tight: TIGHT_CONTROL_HEIGHT, - compact: COMPACT_CONTROL_HEIGHT, - field: FIELD_CONTROL_HEIGHT, + sm: bySizing(CONTROL_SIZES.sm), + md: bySizing(CONTROL_SIZES.md), +} satisfies Record; + +/** A framed control in a header row, such as a split button. Touch-sized it is an `sm` control. */ +export const HEADER_CONTROL_HEIGHT = bySizing({ + touch: CONTROL_SIZES.sm.touch, + pointer: POINTER_HEADER_CONTROL_HEIGHT, +}); + +/** + * Square icon-only buttons. Touch-sized, every icon button is one touch target; pointer-sized, + * header buttons are larger than toolbar buttons. + */ +export const ICON_BUTTON_SIZES = { + large: { touch: TOUCH_TARGET, pointer: POINTER_HEADER_CONTROL_HEIGHT }, + small: { touch: TOUCH_TARGET, pointer: 20 }, +} satisfies Record<"large" | "small", SizingValue>; + +/** + * The height of a dense toolbar row, such as the composer's. Touch-sized it is one touch target; + * its controls are disc-sized and centre in it. + */ +export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue; +export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES); + +/** + * The disc of a filled toolbar control, such as the composer's send and stop, and the frame and + * highlight of every other toolbar control (`toolbarSlot`). Pointer-sized it fills the row. + */ +export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue; +export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES); + +/** + * The space between adjacent toolbar controls. Touch-sized it is the visible space between what + * the controls paint, because each control lays out at its painted width (`toolbarSlot`). + */ +export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue; +export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS); + +/** + * A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight. + * Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches + * into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square + * control. + */ +export function toolbarSlot(shapeWidth: number): { + width: LayoutValue; + height: LayoutValue; + marginHorizontal: LayoutValue; +} { + return { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + marginHorizontal: bySizing({ + touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2, + pointer: 0, + }), + }; +} + +/** + * A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized + * its padding reaches into the gaps, so its content is what the row spaces. + */ +export function toolbarContentSlot(padding: number): { + height: LayoutValue; + paddingHorizontal: number; + marginHorizontal: LayoutValue; +} { + return { + height: TOOLBAR_DISC_SIZE, + paddingHorizontal: padding, + marginHorizontal: bySizing({ touch: -padding, pointer: 0 }), + }; +} + +/** + * Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body + * text, so each step is larger. + */ +export type IconSize = keyof typeof ICON_SIZE; +export const TOUCH_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; + +export function iconSizeFor(size: IconSize): SizingValue { + return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] }; +} + +/** + * The glyph a toolbar control paints. Touch-sized every toolbar glyph matches the header icons: + * 20pt at the app's 1.5px stroke, resting at `foreground`. Pointer-sized it is the `md` glyph at + * the same stroke and rests muted until hovered. + */ +export type ToolbarGlyphTone = "foreground" | "muted"; + +export interface ToolbarGlyph { + size: number; + strokeWidth: number; + /** The tone at rest; a hovering pointer brightens it to `foreground`. */ + tone: ToolbarGlyphTone; +} + +/** `icon` is an icon-only control's glyph; `label` is a glyph paired with a label. */ +export interface ToolbarGlyphs { + icon: ToolbarGlyph; + label: ToolbarGlyph; +} + +const POINTER_TOOLBAR_GLYPH: ToolbarGlyph = { + size: ICON_SIZE.md, + strokeWidth: ICON_STROKE_WIDTH, + tone: "muted", }; -export const buttonControlHeight: Record = { - xs: CONTROL_HEIGHTS.tight, - sm: CONTROL_HEIGHTS.compact, - md: CONTROL_HEIGHTS.field, - lg: CONTROL_HEIGHTS.field, +const TOOLBAR_GLYPHS: Record = { + touch: { + icon: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" }, + label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" }, + }, + pointer: { icon: POINTER_TOOLBAR_GLYPH, label: POINTER_TOOLBAR_GLYPH }, }; -export const buttonIconSize: Record = { - xs: ICON_SIZE.xs, - sm: ICON_SIZE.sm, - md: ICON_SIZE.md, - lg: ICON_SIZE.lg, +export function toolbarGlyphsFor(sizing: ControlSizing): ToolbarGlyphs { + return TOOLBAR_GLYPHS[sizing]; +} + +/** The slot each toolbar glyph sits in, as stylesheet values. */ +export const TOOLBAR_GLYPH_SIZES = { + icon: bySizing({ touch: TOOLBAR_GLYPHS.touch.icon.size, pointer: POINTER_TOOLBAR_GLYPH.size }), + label: bySizing({ touch: TOOLBAR_GLYPHS.touch.label.size, pointer: POINTER_TOOLBAR_GLYPH.size }), +} satisfies Record; + +/** + * The context ring in a toolbar row. A full circle fills its box while an outline glyph's ink + * sits inset, so touch-sized the ring is 18pt, the height of a 20pt glyph's ink. Its 2px stroke + * is half a pixel over the glyphs' so the thin circle does not read lighter than them. + * Pointer-sized it is the `md` glyph. + */ +export interface ToolbarRing { + size: number; + strokeWidth: number; +} + +export const TOOLBAR_RINGS = { + touch: { size: 18, strokeWidth: 2 }, + pointer: { size: ICON_SIZE.md, strokeWidth: 2 }, +} satisfies Record; + +export function toolbarGlyphTone(glyph: ToolbarGlyph, hovered: boolean): ToolbarGlyphTone { + return hovered ? "foreground" : glyph.tone; +} + +export function toolbarGlyphColor(theme: Theme, tone: ToolbarGlyphTone): string { + return tone === "foreground" ? theme.colors.foreground : theme.colors.foregroundMuted; +} + +/** + * The solid glyph inside a toolbar disc, such as the stop square. It stays at the `md` glyph + * size: at the toolbar glyph size the square would fill half its disc. Send's outline arrow + * takes the toolbar glyph. + */ +export const TOOLBAR_DISC_GLYPH_SIZES = iconSizeFor("md"); + +/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */ +export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number { + return CONTROL_SIZES[size][controlSizingForLayout(isCompact)]; +} + +export const controlIconSize: Record = { + sm: ICON_SIZE.xs, + md: ICON_SIZE.sm, }; -export const segmentedIconSize: Record = { - xs: ICON_SIZE.xs, - sm: ICON_SIZE.sm, - md: ICON_SIZE.md, -}; +export interface SwitchGeometry { + trackWidth: number; + trackHeight: number; + thumbSize: number; + /** How far the thumb slides between off and on. */ + thumbTravel: number; + /** The control height the switch reserves, so its row lines up with one holding a button. */ + reservedHeight: number; +} -export const switchGeometry = { - trackWidth: SWITCH_TRACK_WIDTH, - trackHeight: SWITCH_TRACK_HEIGHT, - thumbSize: SWITCH_THUMB_SIZE, - thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE), -}; +function switchSize(trackWidth: number, trackHeight: number, thumbSize: number) { + const inset = (trackHeight - thumbSize) / 2; + return { trackWidth, trackHeight, thumbSize, thumbTravel: trackWidth - thumbSize - inset * 2 }; +} + +const SWITCH_POINTER = switchSize(34, 20, 16); +const SWITCH_TOUCH = switchSize(44, 26, 22); + +/** + * A switch follows the same two sizes as every other control. Its thumb travel feeds an + * animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint. + */ +export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry { + const touchSized = controlSizingForLayout(isCompact) === "touch"; + const track = size === "md" && touchSized ? SWITCH_TOUCH : SWITCH_POINTER; + return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) }; +} function fieldLineHeight(fontSize: number): number { return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO); @@ -88,14 +281,21 @@ function fieldVerticalPadding( } /** - * Alerts share the button's size names, text size, and icon size (`buttonIconSize`). + * Alert density steps. Each step's icon matches the text it leads. * The icon and the first text sit in one centered lead row; everything below is indented * by the icon slot plus its gap so it starts at the lead text's left edge. Title and * description use one font size; only the container gap separates them. */ +export const ALERT_ICON_SIZE: Record = { + xs: ICON_SIZE.xs, + sm: ICON_SIZE.sm, + md: ICON_SIZE.md, + lg: ICON_SIZE.lg, +}; + function createAlertGeometry(theme: Theme) { function alertSize(input: { - size: ButtonControlSize; + size: AlertSize; fontSize: number; paddingVertical: number; paddingHorizontal: number; @@ -103,7 +303,7 @@ function createAlertGeometry(theme: Theme) { iconGap: number; textGap: number; }) { - const iconSize = buttonIconSize[input.size]; + const iconSize = ALERT_ICON_SIZE[input.size]; return { container: { paddingVertical: input.paddingVertical, @@ -155,7 +355,7 @@ function createAlertGeometry(theme: Theme) { iconGap: theme.spacing[4], textGap: theme.spacing[1], }), - } satisfies Record; + } satisfies Record; } export function getControlInteractionPhase( @@ -188,45 +388,26 @@ 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 fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm); 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], - paddingVertical: fieldVerticalPadding( - CONTROL_HEIGHTS.compact, - fieldTextSmLineHeight, - controlBorderWidth, + minHeight: CONTROL_HEIGHTS.sm, + paddingHorizontal: theme.spacing[2], + paddingVertical: mapLayout(CONTROL_HEIGHTS.sm, (height) => + fieldVerticalPadding(height, fieldTextSmLineHeight, controlBorderWidth), ), borderRadius: theme.borderRadius.md, }; const fieldControlMd = { - minHeight: CONTROL_HEIGHTS.field, - paddingHorizontal: theme.spacing[4], - paddingVertical: fieldVerticalPadding( - CONTROL_HEIGHTS.field, - fieldTextMdLineHeight, - controlBorderWidth, + minHeight: CONTROL_HEIGHTS.md, + paddingHorizontal: theme.spacing[3], + paddingVertical: mapLayout(CONTROL_HEIGHTS.md, (height) => + fieldVerticalPadding(height, fieldTextMdLineHeight, controlBorderWidth), ), borderRadius: theme.borderRadius.lg, }; - const fieldTextXs = { - fontSize: theme.fontSize.sm, - lineHeight: fieldTextXsLineHeight, - }; const fieldTextSm = { - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, lineHeight: fieldTextSmLineHeight, }; const fieldTextMd = { @@ -234,43 +415,28 @@ export function createControlGeometry(theme: Theme) { lineHeight: fieldTextMdLineHeight, }; const alert = createAlertGeometry(theme); - const switchControl = { - minHeight: CONTROL_HEIGHTS.compact, - justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT, - } satisfies { minHeight: number; justifyContent: "center" }; return { - buttonXs: { - minHeight: buttonControlHeight.xs, - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.xl, - }, buttonSm: { - minHeight: buttonControlHeight.sm, + minHeight: CONTROL_HEIGHTS.sm, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.xl, + borderRadius: theme.borderRadius.full, }, buttonMd: { - minHeight: buttonControlHeight.md, - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius["2xl"], + minHeight: CONTROL_HEIGHTS.md, + paddingHorizontal: bySizing({ touch: theme.spacing[6], pointer: theme.spacing[4] }), + borderRadius: theme.borderRadius.full, }, - buttonLg: { - minHeight: buttonControlHeight.lg, - paddingHorizontal: theme.spacing[6], - borderRadius: theme.borderRadius["2xl"], - }, - buttonText: { - fontSize: theme.fontSize.base, - }, - buttonTextXs: { + // A button with only an icon is a circle of its size's height. + buttonIconOnlySm: { width: CONTROL_HEIGHTS.sm, paddingHorizontal: 0 }, + buttonIconOnlyMd: { width: CONTROL_HEIGHTS.md, paddingHorizontal: 0 }, + buttonTextSm: { fontSize: theme.fontSize.sm, }, - alert, - formTextInputXs: { - ...fieldControlXs, - ...fieldTextXs, + buttonTextMd: { + fontSize: theme.fontSize.base, }, + alert, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -279,14 +445,8 @@ export function createControlGeometry(theme: Theme) { ...fieldControlMd, ...fieldTextMd, }, - formTextInput: { - ...fieldControlMd, - ...fieldTextMd, - }, - fieldControlXs, fieldControlSm, fieldControlMd, - fieldTextXs, fieldTextSm, fieldTextMd, controlRest: { @@ -311,39 +471,26 @@ export function createControlGeometry(theme: Theme) { controlDisabled: { opacity: theme.opacity[50], }, - switchControl, - segmentedContainerXs: { - minHeight: CONTROL_HEIGHTS.tight, - padding: 0, - }, segmentedContainerSm: { - minHeight: CONTROL_HEIGHTS.compact, + minHeight: CONTROL_HEIGHTS.sm, padding: 0, }, segmentedContainerMd: { - minHeight: CONTROL_HEIGHTS.field, + minHeight: CONTROL_HEIGHTS.md, padding: 0, }, - segmentedSegmentXs: { - minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.xl, - }, segmentedSegmentSm: { - minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2, + minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height - SEGMENTED_INSET * 2), paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.xl, + borderRadius: theme.borderRadius.full, }, segmentedSegmentMd: { - minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2, + minHeight: mapLayout(CONTROL_HEIGHTS.md, (height) => height - SEGMENTED_INSET * 2), paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius["2xl"], - }, - segmentedLabelXs: { - fontSize: theme.fontSize.sm, + borderRadius: theme.borderRadius.full, }, segmentedLabelSm: { - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, }, segmentedLabelMd: { fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/ui/control-size.tsx b/packages/app/src/components/ui/control-size.tsx index c6bcbbea78..a6a4bcb389 100644 --- a/packages/app/src/components/ui/control-size.tsx +++ b/packages/app/src/components/ui/control-size.tsx @@ -1,23 +1,21 @@ 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"; +import type { ControlSize } from "@/components/ui/control-geometry"; // 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); +const ControlSizeContext = createContext(null); export function ControlSizeProvider({ size, children, }: { - size: ContainedControlSize; + size: ControlSize; children: ReactNode; }) { return {children}; } /** The size the surrounding container asks for, or null outside one. */ -export function useContainedControlSize(): ContainedControlSize | null { +export function useContainedControlSize(): ControlSize | null { return useContext(ControlSizeContext); } diff --git a/packages/app/src/components/ui/control-sizing.ts b/packages/app/src/components/ui/control-sizing.ts new file mode 100644 index 0000000000..6dc371af76 --- /dev/null +++ b/packages/app/src/components/ui/control-sizing.ts @@ -0,0 +1,68 @@ +import { hasTouchScreen } from "@/hooks/pointer-capabilities"; + +// The sizing gate, apart from the control sizes so that low-level modules such as +// `constants/layout` can size by it without loading the theme. Import it through +// `@/components/ui/control-geometry`, which re-exports it. + +/** + * Every size comes in a touch value and a pointer value. Touch values follow Apple's Human + * Interface Guidelines for iOS and iPadOS: a 44pt default control and hit region, nothing + * smaller than its 28pt minimum. Pointer values sit one step down the same button ladder. + */ +export type ControlSizing = "touch" | "pointer"; + +export interface ControlSizingFacts { + /** The layout is below the `md` breakpoint. */ + compactLayout: boolean; + /** The device has a touch screen; see `hasTouchScreen`. */ + touchScreen: boolean; +} + +/** + * The one sizing gate: touch-sized in the compact layout or on any device with a touch screen, + * pointer-sized otherwise. A touch screen keeps touch sizing when a mouse or keyboard is attached. + */ +export function resolveControlSizing({ + compactLayout, + touchScreen, +}: ControlSizingFacts): ControlSizing { + if (compactLayout || touchScreen) return "touch"; + return "pointer"; +} + +// Read once, so attaching a mouse or keyboard later never flips the sizing. +const DEVICE_HAS_TOUCH_SCREEN = hasTouchScreen(); + +/** The sizing on this device for a layout, for code that sizes outside a stylesheet. */ +export function controlSizingForLayout(compactLayout: boolean): ControlSizing { + return resolveControlSizing({ compactLayout, touchScreen: DEVICE_HAS_TOUCH_SCREEN }); +} + +/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */ +// A mapped type, not an interface: Unistyles checks breakpoint objects against an index +// signature, which interfaces never satisfy. +export type LayoutValue = Record<"xs" | "md", number>; + +export type SizingValue = Record; + +/** + * A size as a Unistyles breakpoint value. The compact layout always takes the touch value; from + * `md` up the device decides. + */ +export function bySizing( + sizes: SizingValue, + touchScreen: boolean = DEVICE_HAS_TOUCH_SCREEN, +): LayoutValue { + return { + xs: sizes[resolveControlSizing({ compactLayout: true, touchScreen })], + md: sizes[resolveControlSizing({ compactLayout: false, touchScreen })], + }; +} + +/** Derives a breakpoint value from another, such as a row as tall as its control plus padding. */ +export function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue { + return { xs: map(value.xs), md: map(value.md) }; +} + +/** The smallest touch target, and the size of every touch-sized icon button, row, and bar. */ +export const TOUCH_TARGET = 44; diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index a276e40652..7504bbece4 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -1,11 +1,11 @@ import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; -import { Text, View, type ViewStyle } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ChevronDown } from "lucide-react-native"; import { - buttonIconSize, + controlIconSize, createControlGeometry, - type ButtonControlSize, + type ControlSize, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; @@ -22,7 +22,7 @@ interface DropdownTriggerProps extends Omit [ styles.base, @@ -58,7 +49,7 @@ export function DropdownTrigger({ ], [disabled, sizeStyle], ); - const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]); + const labelStyle = useMemo(() => [styles.label, size === "sm" && styles.labelSm], [size]); return ( @@ -67,7 +58,7 @@ export function DropdownTrigger({ {children}
- + ); @@ -84,10 +75,8 @@ const styles = StyleSheet.create((theme) => { borderColor: theme.colors.borderAccent, backgroundColor: "transparent", }, - xs: geometry.buttonXs, sm: geometry.buttonSm, md: geometry.buttonMd, - lg: geometry.buttonLg, pressed: { opacity: 0.85, }, @@ -97,9 +86,9 @@ const styles = StyleSheet.create((theme) => { label: { flexShrink: 1, color: theme.colors.foreground, - ...geometry.buttonText, + ...geometry.buttonTextMd, }, - labelXs: geometry.buttonTextXs, + labelSm: geometry.buttonTextSm, chevron: { transform: [{ translateY: 1 }], }, diff --git a/packages/app/src/components/ui/edge-fade.tsx b/packages/app/src/components/ui/edge-fade.tsx index de404aeed8..f5a36ce30b 100644 --- a/packages/app/src/components/ui/edge-fade.tsx +++ b/packages/app/src/components/ui/edge-fade.tsx @@ -4,9 +4,10 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac import { withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; -type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace"; +type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3"; const backdropColorMappings: Record { 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/external-link.tsx b/packages/app/src/components/ui/external-link.tsx index a29018b042..cdf98fc0fc 100644 --- a/packages/app/src/components/ui/external-link.tsx +++ b/packages/app/src/components/ui/external-link.tsx @@ -1,5 +1,5 @@ import { useCallback, type ReactNode } from "react"; -import { Pressable, Text } from "react-native"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ArrowUpRight } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -41,10 +41,10 @@ export function ExternalLink({ accessibilityRole="link" accessibilityLabel={accessibilityLabel ?? label} testID={testID} - style={styles.trigger} > - {children ?? label} - + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( + {children ?? label} + )} ); @@ -62,7 +62,27 @@ export function ExternalLink({ ); } +/** + * What an external link looks like: muted text and an outward arrow, both turning foreground on + * hover. For a pressable that owns its own hover, such as a settings row that opens a page. + */ +export function ExternalLinkLabel({ + hovered = false, + children, +}: { + hovered?: boolean; + children?: ReactNode; +}) { + return ( + + {children ? {children} : null} + + + ); +} + const iconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const hoveredIconColor = (theme: Theme) => ({ color: theme.colors.foreground }); const ThemedArrowUpRight = withUnistyles(ArrowUpRight); const styles = StyleSheet.create((theme) => ({ @@ -75,6 +95,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + hovered: { + color: theme.colors.foreground, + }, tooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index 349d658e4f..a85d9a2712 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -21,18 +21,15 @@ import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { createControlGeometry, resolveControlInteractionStyles, - type FieldControlSize, + type ControlSize, } 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, +const FIELD_CHROME = { sm: "chromeSm", md: "chromeMd" } as const satisfies Record< + ControlSize, string >; +const FIELD_TEXT = { sm: "inputSm", md: "inputMd" } as const satisfies Record; interface FieldProps { label: string; @@ -77,7 +74,7 @@ export function Field({ label, children, trailing, hint, error, testID }: FieldP } type FormTextInputProps = AdaptiveTextInputProps & { - size?: FieldControlSize; + size?: ControlSize; }; type FlatFormTextInputStyle = ViewStyle & TextStyle; @@ -274,9 +271,6 @@ const formInputStyles = StyleSheet.create((theme) => { chrome: { backgroundColor: theme.colors.surface2, }, - chromeXs: { - ...geometry.fieldControlXs, - }, chromeSm: { ...geometry.fieldControlSm, }, @@ -304,9 +298,6 @@ const formInputStyles = StyleSheet.create((theme) => { outlineColor: "transparent", outlineWidth: 0, }, - inputXs: { - ...geometry.fieldTextXs, - }, inputSm: { ...geometry.fieldTextSm, }, diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index fd81294f80..164120c491 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { createContext, useCallback, @@ -14,11 +15,7 @@ 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 { - OverlayLayerProvider, - useOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { FloatingSurface } from "@/components/ui/floating"; import { measureFloatingPanelPortalHost, @@ -127,7 +124,7 @@ function WebHoverCard({ }, [clearGraceTimer, disabled]); const keyPressed = useCallback( - (event: KeyboardEvent): boolean => { + (event: KeyboardInputEvent): boolean => { if (!isWeb) return false; const trigger = triggerRef.current as unknown as HTMLElement | null; if (!trigger) return false; @@ -139,7 +136,7 @@ function WebHoverCard({ return true; } else if ( focusInside() && - event.target === trigger && + document.activeElement === trigger && ["ArrowDown", "Enter", " "].includes(event.key) ) { event.preventDefault(); @@ -158,7 +155,8 @@ function WebHoverCard({ [clearGraceTimer, focusInside, open, openNow], ); - const setOverlayScope = useWebOverlayRegistration({ + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], active: !disabled && open, layer, onKeyDown: keyPressed, @@ -210,9 +208,9 @@ function WebHoverCard({ ) keyPressed(event); }; + // Only a scroll that moves the trigger detaches the card. A streaming chat beside it does not. const scrolled = (event: Event) => { - if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node)) - close(); + if ((event.target as Node).contains(trigger)) close(); }; document.addEventListener("focusin", focusEntered); document.addEventListener("focusout", focusLeft); diff --git a/packages/app/src/components/ui/icon-button-chrome.ts b/packages/app/src/components/ui/icon-button-chrome.ts index 75735ac986..42ba47a80a 100644 --- a/packages/app/src/components/ui/icon-button-chrome.ts +++ b/packages/app/src/components/ui/icon-button-chrome.ts @@ -1,17 +1,24 @@ import { createContext, useCallback, useContext } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { + bySizing, + controlSizingForLayout, + ICON_BUTTON_SIZES, + TOUCH_ICON_SIZE, + type SizingValue, +} from "@/components/ui/control-geometry"; import { ICON_SIZE } from "@/styles/theme"; export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color"; export type IconButtonChromeSize = "large" | "small"; -const SMALL_ICON_BUTTON_SIZE = 20; -/** Width and height of a large icon button on compact layouts. */ -export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32; -const COMPACT_SMALL_ICON_BUTTON_SIZE = 32; +// Touch-sized, every icon button is one target with one glyph size. +const GLYPH_SIZES = { + large: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.md }, + small: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.sm }, +} satisfies Record; export interface IconButtonChromeState { hovered?: boolean; @@ -22,7 +29,7 @@ export interface IconButtonChromeState { function resolveIconButtonFrame(size: IconButtonChromeSize, compact: boolean) { if (size === "large") return styles.large; - return compact ? styles.smallCompact : styles.small; + return controlSizingForLayout(compact) === "touch" ? styles.smallTouch : styles.small; } interface IconButtonChromeOptions { @@ -92,24 +99,17 @@ export function iconButtonChromeFrameStyle( } export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number { - if (size === "large") return ICON_SIZE.md; - return compact ? 18 : 14; + return GLYPH_SIZES[size][controlSizingForLayout(compact)]; } export function smallIconButtonChromeFrameSize(compact = false): number { - return compact ? COMPACT_SMALL_ICON_BUTTON_SIZE : SMALL_ICON_BUTTON_SIZE; + return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)]; } const styles = StyleSheet.create((theme) => ({ large: { - width: { - xs: COMPACT_LARGE_ICON_BUTTON_SIZE, - md: HEADER_CONTROL_HEIGHT, - }, - height: { - xs: COMPACT_LARGE_ICON_BUTTON_SIZE, - md: HEADER_CONTROL_HEIGHT, - }, + width: bySizing(ICON_BUTTON_SIZES.large), + height: bySizing(ICON_BUTTON_SIZES.large), padding: 0, borderRadius: theme.borderRadius.md, alignItems: "center", @@ -119,8 +119,8 @@ const styles = StyleSheet.create((theme) => ({ outlineColor: "transparent", }, small: { - width: SMALL_ICON_BUTTON_SIZE, - height: SMALL_ICON_BUTTON_SIZE, + width: ICON_BUTTON_SIZES.small.pointer, + height: ICON_BUTTON_SIZES.small.pointer, padding: 0, borderRadius: theme.borderRadius.md, alignItems: "center", @@ -129,9 +129,9 @@ const styles = StyleSheet.create((theme) => ({ outlineWidth: 0, outlineColor: "transparent", }, - smallCompact: { - width: COMPACT_SMALL_ICON_BUTTON_SIZE, - height: COMPACT_SMALL_ICON_BUTTON_SIZE, + smallTouch: { + width: ICON_BUTTON_SIZES.small.touch, + height: ICON_BUTTON_SIZES.small.touch, padding: 0, borderRadius: theme.borderRadius.md, alignItems: "center", 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/keyboard-navigation.ts b/packages/app/src/components/ui/menu/keyboard-navigation.ts new file mode 100644 index 0000000000..ca0d854436 --- /dev/null +++ b/packages/app/src/components/ui/menu/keyboard-navigation.ts @@ -0,0 +1,4 @@ +import type { KeyboardInputEvent } from "@/keyboard"; +export function navigateMenuKeyboard(_event: KeyboardInputEvent): boolean { + return false; +} diff --git a/packages/app/src/components/ui/menu/keyboard-navigation.web.ts b/packages/app/src/components/ui/menu/keyboard-navigation.web.ts new file mode 100644 index 0000000000..ba2dd1f1e5 --- /dev/null +++ b/packages/app/src/components/ui/menu/keyboard-navigation.web.ts @@ -0,0 +1,37 @@ +import type { KeyboardInputEvent } from "@/keyboard"; +export function navigateMenuKeyboard(event: KeyboardInputEvent): boolean { + const target = document.activeElement; + const surface = target?.closest('[data-menu-surface="true"]'); + if (!surface) return false; + const items = Array.from( + surface.querySelectorAll( + '[data-menu-item="true"]:not([data-menu-disabled="true"])', + ), + ); + if (items.length === 0) return false; + const currentIndex = items.findIndex((item) => item === document.activeElement); + let nextIndex: number | null = null; + if (event.key === "ArrowDown") { + if (currentIndex < 0) nextIndex = 0; + else nextIndex = (currentIndex + 1) % items.length; + } + if (event.key === "ArrowUp") { + if (currentIndex < 0) nextIndex = items.length - 1; + else nextIndex = (currentIndex - 1 + items.length) % items.length; + } + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = items.length - 1; + if (nextIndex !== null) { + event.preventDefault(); + event.stopPropagation(); + items[nextIndex]?.focus(); + return true; + } + if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) { + event.preventDefault(); + event.stopPropagation(); + items[currentIndex]?.click(); + return true; + } + return false; +} 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-geometry.ts b/packages/app/src/components/ui/menu/menu-geometry.ts index 089f4afa67..b0b53e697e 100644 --- a/packages/app/src/components/ui/menu/menu-geometry.ts +++ b/packages/app/src/components/ui/menu/menu-geometry.ts @@ -1,2 +1,5 @@ /** Shared geometry for menu rows and controls that align with them. */ -export const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const; +import { density } from "@/styles/density"; + +/** A menu row is a tight row: touch-sized wherever controls are (see `resolveControlSizing`). */ +export const MENU_ITEM_HEIGHT = density.tightRowHeight; 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 9631dbbe9a..c48fda7156 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -1,7 +1,11 @@ +import { navigateMenuKeyboard } from "./keyboard-navigation"; +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useState, type ReactElement, @@ -11,7 +15,6 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Dimensions, - Modal, Platform, Pressable, View, @@ -23,14 +26,10 @@ import { StyleSheet } from "react-native-unistyles"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; import type { KeyboardFocusScope } from "@/keyboard/actions"; -import { - getOverlayRoot, - OverlayLayerProvider, - useOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { computePosition, + fitSurfaceWidth, getTransformOrigin, measureElement, type Alignment, @@ -47,56 +46,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]); } /** @@ -224,8 +208,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; /** @@ -259,7 +241,6 @@ export function AnchoredSurface({ fullWidth = false, horizontalPadding = 16, scrollable = false, - revision, backdrop = true, onPointerEnter, onPointerLeave, @@ -269,33 +250,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(() => { @@ -311,11 +284,7 @@ export function AnchoredSurface({ const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } - : { - ...(typeof width === "number" ? { width } : null), - ...(typeof minWidth === "number" ? { minWidth } : null), - ...(typeof maxWidth === "number" ? { maxWidth } : null), - }; + : fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth); return [ resolvedWidthStyle, { @@ -383,7 +352,7 @@ export function AnchoredSurface({ dataSet={surfaceDataSet} style={styles.content} frameStyle={frameStyle} - entering={placed ? contentEntering : undefined} + entering={placed ? nativeContentEntering : undefined} exiting={ !placed || isWeb || !onExited ? undefined @@ -430,8 +399,8 @@ export function MenuOverlay({ }): ReactElement | null { const floatingLayer = useOverlayLayer("floating"); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -439,44 +408,15 @@ export function MenuOverlay({ return true; } - const target = event.target instanceof Element ? event.target : null; - const surface = target?.closest('[data-menu-surface="true"]'); - if (!surface) return false; - const items = Array.from( - surface.querySelectorAll( - '[data-menu-item="true"]:not([data-menu-disabled="true"])', - ), - ); - if (items.length === 0) return false; - const currentIndex = items.findIndex((item) => item === document.activeElement); - let nextIndex: number | null = null; - if (event.key === "ArrowDown") - nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length; - if (event.key === "ArrowUp") - nextIndex = - currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length; - if (event.key === "Home") nextIndex = 0; - if (event.key === "End") nextIndex = items.length - 1; - if (nextIndex !== null) { - event.preventDefault(); - event.stopPropagation(); - items[nextIndex]?.focus(); - return true; - } - if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) { - event.preventDefault(); - event.stopPropagation(); - items[currentIndex]?.click(); - return true; - } - return false; + return navigateMenuKeyboard(event); }, [onClose], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && visible, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], + active: visible, layer: floatingLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, restoreFocusRef, }); @@ -488,7 +428,7 @@ export function MenuOverlay({ {...{ onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(), }} - ref={setWebOverlayScope} + ref={setOverlayScope} collapsable={false} style={[ styles.overlay, 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/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index d828c26c4d..05d7fedec4 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -4,8 +4,8 @@ import type { StyleProp, TextStyle, ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { createControlGeometry, - segmentedIconSize, - type SegmentedControlSize, + controlIconSize, + type ControlSize, } from "@/components/ui/control-geometry"; import { useContainedControlSize } from "@/components/ui/control-size"; import type { Theme } from "@/styles/theme"; @@ -24,7 +24,7 @@ interface SegmentedControlProps { options: SegmentedControlOption[]; value: T; onValueChange: (value: T) => void; - size?: SegmentedControlSize; + size?: ControlSize; hideLabels?: boolean; style?: StyleProp; testID?: string; @@ -57,14 +57,13 @@ export function SegmentedControl({ 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 }, md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd }, }[size]; const containerSizeStyle = sizeStyles.container; const segmentSizeStyle = sizeStyles.segment; const labelSizeStyle = sizeStyles.label; - const iconSize = segmentedIconSize[size]; + const iconSize = controlIconSize[size]; const containerStyle = useMemo( () => [styles.container, containerSizeStyle, style], @@ -173,9 +172,6 @@ const styles = StyleSheet.create((theme) => { backgroundColor: "transparent", gap: theme.spacing[1], }, - containerXs: { - ...geometry.segmentedContainerXs, - }, containerSm: { ...geometry.segmentedContainerSm, }, @@ -189,9 +185,6 @@ const styles = StyleSheet.create((theme) => { flexShrink: 0, gap: theme.spacing[1], }, - segmentXs: { - ...geometry.segmentedSegmentXs, - }, segmentSm: { ...geometry.segmentedSegmentSm, }, @@ -218,9 +211,6 @@ const styles = StyleSheet.create((theme) => { color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, }, - labelXs: { - ...geometry.segmentedLabelXs, - }, labelSm: { ...geometry.segmentedLabelSm, }, diff --git a/packages/app/src/components/ui/select-field.tsx b/packages/app/src/components/ui/select-field.tsx index a1228cb8e1..42af6786e3 100644 --- a/packages/app/src/components/ui/select-field.tsx +++ b/packages/app/src/components/ui/select-field.tsx @@ -13,7 +13,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com import { createControlGeometry, resolveControlInteractionStyles, - type FieldControlSize, + type ControlSize, } from "@/components/ui/control-geometry"; import { Field } from "@/components/ui/form-field"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -55,7 +55,7 @@ export interface SelectFieldProps { searchable?: boolean; searchPlaceholder?: string; title?: string; - size?: FieldControlSize; + size?: ControlSize; getValueKey?: (value: TValue) => string; renderOption?: (input: SelectFieldRenderOptionInput) => ReactElement; triggerLeading?: ReactNode; @@ -75,7 +75,7 @@ export interface SelectFieldTriggerProps { disabled?: boolean; loading?: boolean; leading?: ReactNode; - size?: FieldControlSize; + size?: ControlSize; testID?: string; } diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index 6be0cf353e..3734902b8d 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -1,7 +1,7 @@ import React, { useMemo, type ReactElement } from "react"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; @@ -17,7 +17,7 @@ export function Shortcut({ style?: StyleProp; textStyle?: StyleProp; }): ReactElement | null { - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); const displayChord = normalizeDisplayChord(chord, keys); const shortcutOs = getShortcutOs(); diff --git a/packages/app/src/components/ui/skeleton.tsx b/packages/app/src/components/ui/skeleton.tsx new file mode 100644 index 0000000000..76b710913d --- /dev/null +++ b/packages/app/src/components/ui/skeleton.tsx @@ -0,0 +1,34 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Animated, type StyleProp, type ViewStyle } from "react-native"; + +/** One looping value per skeleton, shared by its shapes so they pulse together. */ +export function useSkeletonPulse(): Animated.Value { + const pulse = useRef(new Animated.Value(0)).current; + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }), + Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }), + ]), + ); + animation.start(); + return () => animation.stop(); + }, [pulse]); + return pulse; +} + +/** A placeholder shape; `style` sets its size, radius and color. */ +export function SkeletonPulse({ + pulse, + style, +}: { + pulse: Animated.Value; + style: StyleProp; +}) { + const opacity = pulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.4, 0.8], + }); + const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]); + return ; +} diff --git a/packages/app/src/components/ui/switch.test.tsx b/packages/app/src/components/ui/switch.test.tsx index 1db67418ff..96fb57df79 100644 --- a/packages/app/src/components/ui/switch.test.tsx +++ b/packages/app/src/components/ui/switch.test.tsx @@ -86,6 +86,7 @@ vi.mock("react-native-unistyles", () => ({ ) => (props: Record) => React.createElement(Component, { ...props, ...mapping?.(theme) }), + useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), })); describe("Switch", () => { diff --git a/packages/app/src/components/ui/switch.tsx b/packages/app/src/components/ui/switch.tsx index b4c035eea0..618eae8c11 100644 --- a/packages/app/src/components/ui/switch.tsx +++ b/packages/app/src/components/ui/switch.tsx @@ -13,7 +13,9 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { createControlGeometry, switchGeometry } from "@/components/ui/control-geometry"; +import { switchGeometryForLayout, type SwitchGeometry } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; +import { useIsCompactFormFactor } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; interface SwitchProps { @@ -29,6 +31,7 @@ const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) }; interface SwitchTrackProps { value: boolean; + geometry: SwitchGeometry; trackOffColor: string; trackOnColor: string; thumbOffColor: string; @@ -37,6 +40,7 @@ interface SwitchTrackProps { function SwitchTrack({ value, + geometry, trackOffColor, trackOnColor, thumbOffColor, @@ -48,15 +52,36 @@ function SwitchTrack({ backgroundColor: interpolateColor(progress.value, [0, 1], [trackOffColor, trackOnColor]), })); + const { thumbTravel } = geometry; const thumbAnimatedStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor(progress.value, [0, 1], [thumbOffColor, thumbOnColor]), - transform: [{ translateX: progress.value * switchGeometry.thumbTravel }], + transform: [{ translateX: progress.value * thumbTravel }], })); - const trackStyle = useMemo(() => [styles.switchTrack, trackAnimatedStyle], [trackAnimatedStyle]); + const trackSize = useMemo( + () => ({ + width: geometry.trackWidth, + height: geometry.trackHeight, + borderRadius: geometry.trackHeight / 2, + padding: (geometry.trackHeight - geometry.thumbSize) / 2, + }), + [geometry], + ); + const thumbSize = useMemo( + () => ({ + width: geometry.thumbSize, + height: geometry.thumbSize, + borderRadius: geometry.thumbSize / 2, + }), + [geometry], + ); + const trackStyle = useMemo( + () => [styles.switchTrack, trackSize, trackAnimatedStyle], + [trackSize, trackAnimatedStyle], + ); const thumbStyle = useMemo( - () => [styles.switchThumb, styles.thumb, thumbAnimatedStyle], - [thumbAnimatedStyle], + () => [thumbSize, styles.thumb, thumbAnimatedStyle], + [thumbSize, thumbAnimatedStyle], ); return ( @@ -90,10 +115,19 @@ export function Switch({ [disabled, onValueChange, value], ); + const size = useContainedControlSize() ?? "md"; + const isCompact = useIsCompactFormFactor(); + const geometry = useMemo(() => switchGeometryForLayout(size, isCompact), [size, isCompact]); + const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]); const pressableStyle = useMemo( - () => [styles.switchControl, disabled ? styles.disabled : null, style], - [disabled, style], + () => [ + styles.switchControl, + { minHeight: geometry.reservedHeight }, + disabled ? styles.disabled : null, + style, + ], + [disabled, geometry.reservedHeight, style], ); return ( @@ -108,29 +142,18 @@ export function Switch({ testID={testID} style={pressableStyle} > - + ); } const styles = StyleSheet.create((theme) => { - const geometry = createControlGeometry(theme); - return { switchControl: { - ...geometry.switchControl, - }, - switchTrack: { - width: switchGeometry.trackWidth, - height: switchGeometry.trackHeight, - borderRadius: switchGeometry.trackHeight / 2, - padding: (switchGeometry.trackHeight - switchGeometry.thumbSize) / 2, justifyContent: "center", }, - switchThumb: { - width: switchGeometry.thumbSize, - height: switchGeometry.thumbSize, - borderRadius: switchGeometry.thumbSize / 2, + switchTrack: { + justifyContent: "center", }, thumb: { shadowColor: "rgba(0, 0, 0, 0.25)", diff --git a/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx b/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx new file mode 100644 index 0000000000..fd0706d4fd --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx @@ -0,0 +1,61 @@ +import React, { act, useMemo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { View } from "react-native"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { TailEllipsisText } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +const TEXT_STYLE = { fontSize: 16, fontFamily: "monospace" } as const; + +function LabelRow({ text, width }: { text: string; width: number }) { + const rowStyle = useMemo(() => ({ width, flexDirection: "row" as const }), [width]); + return ( + + + + ); +} + +function renderLabel(text: string, width: number): void { + act(() => root.render()); +} + +function shownLabel(): { text: string; visible: boolean } { + const label = host.querySelector('[data-testid="label"]'); + if (!label) throw new Error("label did not render"); + return { text: label.textContent ?? "", visible: getComputedStyle(label).opacity !== "0" }; +} + +it("shows a replacement label that shares its shortened prefixes with the previous one", async () => { + // Wide enough for "Ten second…" in monospace, too narrow for either full label. + const width = 12 * 10; + renderLabel("Ten second stream", width); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true })); + + renderLabel("Ten second thread", width); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true })); +}); + +it("shows a replacement label as wide as the previous one", async () => { + renderLabel("Opus 4.7", 400); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.7", visible: true })); + + renderLabel("Opus 4.8", 400); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.8", visible: true })); +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/index.tsx b/packages/app/src/components/ui/tail-ellipsis-text/index.tsx new file mode 100644 index 0000000000..d8d477def1 --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/index.tsx @@ -0,0 +1,145 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View, type LayoutChangeEvent, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { readMeasuredWidth } from "@/hooks/use-container-width"; +import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve"; + +interface MeasuredWidths { + text: string; + full?: number; + candidates: (number | undefined)[]; +} + +/** + * One line of text that shrinks with its row and, when it does not fit, ends on a whole word + * and an ellipsis ("Ten second…"), never on a space. It measures itself, so it stays invisible + * for the frame before its widths are known. + */ +export function TailEllipsisText({ + text, + style, + testID, +}: { + text: string; + /** Text styles; the component owns its layout. */ + style: StyleProp; + testID?: string; +}) { + const candidates = useMemo(() => tailEllipsisCandidates(text), [text]); + const [boxWidth, setBoxWidth] = useState(undefined); + const [widths, setWidths] = useState({ text, candidates: [] }); + const current = widths.text === text ? widths : { text, candidates: [] }; + + const onBoxLayout = useCallback((event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setBoxWidth(width); + }, []); + const measure = useCallback( + (slot: "full" | number) => (event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width === null) return; + setWidths((previous) => { + const base = previous.text === text ? previous : { text, candidates: [] }; + if (slot === "full") return { ...base, full: width }; + const next = [...base.candidates]; + next[slot] = width; + return { ...base, candidates: next }; + }); + }, + [text], + ); + + const decision = resolveTailEllipsis({ + candidates, + measurements: { boxWidth, fullWidth: current.full, candidateWidths: current.candidates }, + }); + const shownText = decision.kind === "truncated" ? decision.text : text; + + return ( + + {/* Sizes the box: as wide as the text, or as the row allows. */} + + {text} + + + + {shownText} + + + {/* Keyed by the text, so new text remounts the measurer and every width is measured again. + A retained node whose size does not change emits no new layout event. */} + + + {text} + + {candidates.map((candidate, index) => ( + + {candidate} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + // A row, so the absolute measurer below lays its text out at full width on native too. + box: { + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + }, + sizer: { + minWidth: 0, + flexShrink: 1, + opacity: 0, + }, + shown: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, + flexDirection: "row", + alignItems: "center", + }, + shownText: { + minWidth: 0, + flexShrink: 1, + }, + measuring: { + opacity: 0, + }, + measurer: { + position: "absolute", + left: 0, + top: 0, + flexDirection: "row", + alignItems: "flex-start", + opacity: 0, + }, + measured: { + flexShrink: 0, + }, +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts b/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts new file mode 100644 index 0000000000..e65ae7e4a2 --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve"; + +const candidates = tailEllipsisCandidates("Ten second stream"); +const candidateWidths = [34, 80]; + +describe("tail ellipsis", () => { + it("cuts at word boundaries, so no whitespace sits before the ellipsis", () => { + expect(candidates).toEqual(["Ten…", "Ten second…"]); + expect(tailEllipsisCandidates(" GPT-5.1 Codex ")).toEqual(["GPT-5.1…"]); + expect(tailEllipsisCandidates("claude-opus-4-5")).toEqual([]); + }); + + it("shows the full text while it fits", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 132, fullWidth: 132.3, candidateWidths }, + }), + ).toEqual({ kind: "full" }); + }); + + it("keeps the most words that fit with the ellipsis", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 98, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "truncated", text: "Ten second…" }); + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 60, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "truncated", text: "Ten…" }); + }); + + it("lets the platform cut inside a first word too long for the box", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 20, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "clipped" }); + expect( + resolveTailEllipsis({ + candidates: [], + measurements: { boxWidth: 60, fullWidth: 132, candidateWidths: [] }, + }), + ).toEqual({ kind: "clipped" }); + }); + + it("decides nothing until the widths it needs are measured", () => { + expect(resolveTailEllipsis({ candidates, measurements: { candidateWidths } })).toEqual({ + kind: "measuring", + }); + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 98, fullWidth: 132, candidateWidths: [34] }, + }), + ).toEqual({ kind: "measuring" }); + }); +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts b/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts new file mode 100644 index 0000000000..1e7bb61d1c --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts @@ -0,0 +1,47 @@ +const ELLIPSIS = "…"; + +/** + * The word-boundary truncations of `text`, shortest first: "Ten second stream" gives "Ten…" and + * "Ten second…". Cutting at a word keeps whitespace out from before the ellipsis. + */ +export function tailEllipsisCandidates(text: string): string[] { + const words = text.trim().split(/\s+/); + return words.slice(1).map((_, index) => `${words.slice(0, index + 1).join(" ")}${ELLIPSIS}`); +} + +export type TailEllipsis = + | { kind: "measuring" } + | { kind: "full" } + | { kind: "truncated"; text: string } + /** Not even the first word fits: the platform ellipsis cuts inside it, where no space can sit. */ + | { kind: "clipped" }; + +/** Widths in points, measured from the rendered text. */ +export interface TailEllipsisMeasurements { + /** The space the text may take, up to its full width. */ + boxWidth?: number; + fullWidth?: number; + /** One per `tailEllipsisCandidates` entry, in the same order. */ + candidateWidths: readonly (number | undefined)[]; +} + +/** Sub-point slack for widths the layout rounds differently from the text it measures. */ +const ROUNDING_SLACK = 0.5; + +export function resolveTailEllipsis(input: { + candidates: readonly string[]; + measurements: TailEllipsisMeasurements; +}): TailEllipsis { + const { boxWidth, fullWidth, candidateWidths } = input.measurements; + if (boxWidth === undefined || fullWidth === undefined) return { kind: "measuring" }; + if (fullWidth <= boxWidth + ROUNDING_SLACK) return { kind: "full" }; + if (input.candidates.some((_, index) => candidateWidths[index] === undefined)) { + return { kind: "measuring" }; + } + for (let index = input.candidates.length - 1; index >= 0; index -= 1) { + if ((candidateWidths[index] ?? Infinity) <= boxWidth) { + return { kind: "truncated", text: input.candidates[index] }; + } + } + return { kind: "clipped" }; +} diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 697a80fdcf..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,7 +17,6 @@ import { createPortal } from "react-dom"; import { Dimensions, Platform, - Modal, Pressable, View, type PressableProps, diff --git a/packages/app/src/components/ui/trailing-action-scrim.tsx b/packages/app/src/components/ui/trailing-action-scrim.tsx index e106bf3ec5..0b43136bd1 100644 --- a/packages/app/src/components/ui/trailing-action-scrim.tsx +++ b/packages/app/src/components/ui/trailing-action-scrim.tsx @@ -6,9 +6,18 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; export const SCRIM_WIDTH = 48; -const SCRIM_SOLID_OFFSET = "55%"; +// The fade is a fixed length so a wider scrim only adds solid surface under its actions. +const SCRIM_FADE_WIDTH = SCRIM_WIDTH * 0.55; -function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) { +function TrailingActionScrimSvg({ + gradientId, + color, + solidOffset, +}: { + gradientId: string; + color: string; + solidOffset: string; +}) { return ( @@ -16,7 +25,7 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in some engines and leaves a grey fringe. */} - + @@ -39,17 +48,22 @@ const backdropColorMappings: Record { color: /** Fades trailing content into the surface beneath an absolutely overlaid action. */ export function TrailingActionScrim({ backdrop, + width = SCRIM_WIDTH, testID, }: { backdrop: SurfaceBackdrop; + /** Covers more than one trailing action; the fade keeps its length. */ + width?: number; testID?: string; }) { // React-generated ids contain characters that are invalid inside SVG fragment references. const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + const solidOffset = `${Math.round((SCRIM_FADE_WIDTH / width) * 100)}%`; return ( - + @@ -57,11 +71,11 @@ export function TrailingActionScrim({ } const styles = StyleSheet.create({ - scrim: { + scrim: (width: number) => ({ position: "absolute", top: 0, bottom: 0, right: 0, - width: SCRIM_WIDTH, - }, + width, + }), }); 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..6dd1a1426c 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 @@ -306,7 +283,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { {appVersionText} - - - - - - ); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 3a326aed3d..299bd9f27b 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -436,7 +436,7 @@ export function WorkspaceSetupDialog() { desktopMaxWidth={640} > - + ({ color: theme.colors.destructive, lineHeight: 20, }, + composerSlot: { + paddingBottom: theme.spacing[4], + }, composerInputWrapper: { backgroundColor: theme.colors.surface2, }, 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 84e9141b6a..9f72789430 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -125,6 +125,20 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); }); + it("keeps keyboard navigation populated when the sidebar starts hidden", async () => { + await act(async () => { + root?.render( + + + , + ); + }); + expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ + { serverId: "srv", workspaceId: "ws-1" }, + { serverId: "srv", workspaceId: "ws-2" }, + ]); + }); + it("publishes workspace shortcut targets without rendering the sidebar", async () => { await act(async () => { root?.render( diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index 64b8b39100..c93640206a 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -2,6 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import type { AgentControlIcon } from "@/agent-controls/icons"; @@ -41,10 +42,13 @@ export const AgentControlTrigger = forwardRef( }, ref, ) { - const { glyphSize } = useComposerControlLayout(); + const { glyphs } = useComposerControlLayout(); + const glyphRole = showToolbarLabel ? "label" : "icon"; + const glyph = glyphs[glyphRole]; const isSheet = surface === "sheet"; - const resolvedGlyphSize = isSheet ? 16 : glyphSize; - const resolvedIconColor = iconColor ?? styles.iconColor.color; + const toolbarIconColor = + glyph.tone === "foreground" ? styles.iconColorForeground.color : styles.iconColor.color; + const resolvedIconColor = iconColor ?? (isSheet ? styles.iconColor.color : toolbarIconColor); const showValue = isSheet || showToolbarLabel; const triggerStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType) => [ @@ -72,11 +76,11 @@ export const AgentControlTrigger = forwardRef( > {isSheet ? ( - + ) : ( - - + + )} {isSheet ? ( @@ -96,18 +100,17 @@ export const AgentControlTrigger = forwardRef( const styles = StyleSheet.create((theme) => ({ toolbarControl: { - height: 28, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, toolbarIconOnly: { - width: 28, + ...toolbarSlot(TOUCH_ICON_SIZE.md), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", @@ -159,7 +162,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, pressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, disabled: { opacity: 0.5, @@ -167,4 +170,7 @@ const styles = StyleSheet.create((theme) => ({ iconColor: { color: theme.colors.foregroundMuted, }, + iconColorForeground: { + color: theme.colors.foreground, + }, })); diff --git a/packages/app/src/composer/agent-controls/glyph.tsx b/packages/app/src/composer/agent-controls/glyph.tsx index 73b2a220c1..7763d1c2b5 100644 --- a/packages/app/src/composer/agent-controls/glyph.tsx +++ b/packages/app/src/composer/agent-controls/glyph.tsx @@ -1,10 +1,22 @@ import type { ReactNode } from "react"; -import { StyleSheet, View } from "react-native"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { TOOLBAR_GLYPH_SIZES, type ToolbarGlyphs } from "@/components/ui/control-geometry"; -export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) { +/** + * The slot a composer toolbar control's glyph sits in; see `toolbarGlyphsFor`. `label` when the + * glyph sits beside a label, `icon` when the glyph is the whole control. + */ +export function ComposerToolbarGlyph({ + role, + children, +}: { + role: keyof ToolbarGlyphs; + children: ReactNode; +}) { return ( = 20 ? styles.native : styles.web} + style={role === "label" ? styles.labelSlot : styles.iconSlot} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" pointerEvents="none" @@ -14,19 +26,9 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; ); } +const slot = { flexShrink: 0, alignItems: "center", justifyContent: "center" } as const; + const styles = StyleSheet.create({ - web: { - width: 16, - height: 16, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, - native: { - width: 20, - height: 20, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, + iconSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.icon, height: TOOLBAR_GLYPH_SIZES.icon }, + labelSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.label, height: TOOLBAR_GLYPH_SIZES.label }, }); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 72795289cf..f86721fb98 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -61,6 +61,17 @@ import { resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { + controlSizingForLayout, + TOOLBAR_CONTROL_GAP, + bySizing, + toolbarGlyphColor, + type ToolbarGlyphs, + TOUCH_ICON_SIZE, + toolbarContentSlot, + toolbarSlot, +} from "@/components/ui/control-geometry"; +import { DensityIcon } from "@/components/ui/density-icon"; import { readMeasuredWidth } from "@/hooks/use-container-width"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; @@ -70,15 +81,17 @@ import { type AgentControlCommandCenterSource, } from "@/command-center/agent-control-registration"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; -import { isNative } from "@/constants/platform"; import { + composerToolbarGeometry, resolveComposerControlDensity, resolveComposerControlPresentation, - resolveComposerToolbarGlyphSize, type ComposerControlDensity, type ComposerControlPresentation, } from "@/composer/agent-controls/layout"; -import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; +import { + ComposerControlLayoutProvider, + useComposerControlLayout, +} from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; @@ -218,10 +231,10 @@ function getFeatureIconColor( green: { 400: string }; yellow: { 400: string }; }, - foregroundMuted: string, + restColor: string, ): string { if (!isFeatureActive(feature)) { - return foregroundMuted; + return restColor; } switch (getFeatureHighlightColor(feature.id)) { @@ -232,7 +245,7 @@ function getFeatureIconColor( case "yellow": return palette.yellow[400]; default: - return foregroundMuted; + return restColor; } } @@ -460,6 +473,10 @@ function buildAgentProviderModels( return map; } +function densityFolds(density: ComposerControlDensity): boolean { + return resolveComposerControlPresentation(density).foldControls; +} + function buildOpenChangeHandler( selector: AgentControlSelector, setOpenSelector: (next: AgentControlSelector | null) => void, @@ -503,14 +520,20 @@ function ControlledAgentControls({ modelSelectorServerId = null, isCompactLayout, }: ControlledAgentControlsProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isCompactFormFactor = useIsCompactFormFactor(); const isCompact = isCompactLayout ?? isCompactFormFactor; + // Control sizes follow the window layout, as the toolbar's stylesheet does. A narrow pane in a + // wide window (`isCompactLayout`) changes which labels show, never how large a control is. + const toolbarSizing = controlSizingForLayout(isCompactFormFactor); + const toolbarGeometry = useMemo(() => composerToolbarGeometry(toolbarSizing), [toolbarSizing]); const { fontScale } = useWindowDimensions(); const [activeSheet, setActiveSheet] = useState(null); const [openSelector, setOpenSelector] = useState(null); - const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full"; + // A compact composer always folds its controls into the model sheet; the width budget only + // decides whether the model keeps its label. + const minimumDensity: ComposerControlDensity = isCompact ? "compact" : "full"; + const initialDensity = minimumDensity; const [density, setDensity] = useState(initialDensity); const densityRef = useRef(initialDensity); const availableWidthRef = useRef(0); @@ -574,10 +597,10 @@ function ControlledAgentControls({ const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); const layoutContextValue = useMemo( () => ({ - glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"), + glyphs: toolbarGeometry.glyphs, presentation, }), - [presentation], + [presentation, toolbarGeometry], ); const updateDensityForWidth = useCallback( @@ -586,12 +609,20 @@ function ControlledAgentControls({ availableWidth, currentDensity: densityRef.current, controls: controlPresence, + geometry: toolbarGeometry, + minimumDensity, }); if (nextDensity === densityRef.current) return; + // Folding swaps the toolbar controls for the model sheet and back, so whatever menu or + // sheet was open belonged to controls that are gone. + if (densityFolds(nextDensity) !== densityFolds(densityRef.current)) { + setOpenSelector(null); + setActiveSheet(null); + } densityRef.current = nextDensity; setDensity(nextDensity); }, - [controlPresence], + [controlPresence, minimumDensity, toolbarGeometry], ); const handleLayout = useCallback( @@ -633,10 +664,9 @@ function ControlledAgentControls({ selected={args.selected} active={args.active} onPress={args.onPress} - iconColor={theme.colors.foreground} /> ), - [theme.colors.foreground], + [], ); const handleOpenChange = useCallback( @@ -702,8 +732,8 @@ function ControlledAgentControls({ const handleCloseSheet = useCallback(() => { setActiveSheet(null); - if (!isCompact) onDropdownClose?.(); - }, [isCompact, onDropdownClose]); + if (!presentation.foldControls) onDropdownClose?.(); + }, [onDropdownClose, presentation.foldControls]); const handleSelectThinkingAndClose = useCallback( (thinkingOptionId: string) => { @@ -734,7 +764,7 @@ function ControlledAgentControls({ return ( - {!isCompact ? ( + {!presentation.foldControls ? ( @@ -881,7 +912,7 @@ interface DesktopAgentControlsContentProps { }) => ReactElement; modeControl?: AgentModeControlValue | null; presentation: ComposerControlPresentation; - glyphSize: number; + glyphs: ToolbarGlyphs; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; @@ -938,15 +969,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { renderThinkingOption, modeControl, presentation, - glyphSize, + glyphs, activeSheet, handleOpenSheet, handleCloseSheet, modelSelectorServerId, } = props; const modelToolbar = useMemo( - () => ({ glyphSize, showCaret: presentation.showCarets }), - [glyphSize, presentation.showCarets], + () => ({ glyph: glyphs.label, showCaret: presentation.showCarets }), + [glyphs, presentation.showCarets], ); const featuresSheetHeader = useMemo( () => ({ title: t("agentControls.features.title") }), @@ -1067,8 +1098,12 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { accessibilityLabel={t("agentControls.features.open")} testID="agent-controls-features" > - - + + void; }) => ReactElement; modeControl?: AgentModeControlValue | null; - glyphSize: number; + glyphs: ToolbarGlyphs; + showLabels: boolean; modelSelectorServerId: string | null; canSwitchProvider: boolean; } @@ -1182,7 +1218,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { handleOpenChange, renderThinkingOption, modeControl, - glyphSize, + glyphs, + showLabels, modelSelectorServerId, canSwitchProvider, } = props; @@ -1270,7 +1307,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { onRetryProvider={onRetryModelProvider} isRetryingProvider={isRetryingModelProvider} serverId={modelSelectorServerId} - glyphSize={glyphSize} + glyphs={glyphs} + showLabels={showLabels} canSwitchProvider={canSwitchProvider} > {sheetControls} @@ -1294,6 +1332,8 @@ function DesktopFeatureItem({ onActionComplete?: () => void; }) { const { theme } = useUnistyles(); + const { glyphs } = useComposerControlLayout(); + const restColor = toolbarGlyphColor(theme, glyphs.icon.tone); const featureSelector: AgentControlSelector = `feature-${feature.id}`; const featureAnchorRef = useRef(null); @@ -1334,11 +1374,7 @@ function DesktopFeatureItem({ void; - iconColor: string; }) { - const leadingSlot = useMemo(() => , [iconColor]); + const leadingSlot = useMemo( + () => , + [], + ); return ( ({ flexShrink: 1, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, + // Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past + // what the row spaces, so the clip reaches as far. overflow: "hidden", + paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }), + marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }), }, modeBadge: { - height: 28, flexDirection: "row", alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, }, modelControl: { minWidth: 0, @@ -1971,8 +2007,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, modeIconBadge: { - width: 28, - height: 28, + ...toolbarSlot(TOUCH_ICON_SIZE.md), alignItems: "center", justifyContent: "center", paddingHorizontal: 0, @@ -1984,7 +2019,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, modeBadgePressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, disabledBadge: { opacity: 0.5, diff --git a/packages/app/src/composer/agent-controls/layout-context.tsx b/packages/app/src/composer/agent-controls/layout-context.tsx index edf8fec7aa..86a9b8cbfe 100644 --- a/packages/app/src/composer/agent-controls/layout-context.tsx +++ b/packages/app/src/composer/agent-controls/layout-context.tsx @@ -1,19 +1,18 @@ import { createContext, useContext, type ReactNode } from "react"; -import type { ComposerControlPresentation } from "@/composer/agent-controls/layout"; +import { toolbarGlyphsFor, type ToolbarGlyphs } from "@/components/ui/control-geometry"; +import { + resolveComposerControlPresentation, + type ComposerControlPresentation, +} from "@/composer/agent-controls/layout"; interface ComposerControlLayoutValue { - glyphSize: number; + glyphs: ToolbarGlyphs; presentation: ComposerControlPresentation; } const DEFAULT_LAYOUT: ComposerControlLayoutValue = { - glyphSize: 16, - presentation: { - showCarets: true, - showThinkingLabel: true, - showModeLabel: true, - aggregateFeatures: false, - }, + glyphs: toolbarGlyphsFor("pointer"), + presentation: resolveComposerControlPresentation("full"), }; const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT); diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 2e52972b1c..906bc0c48f 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -1,30 +1,43 @@ import { describe, expect, it } from "vitest"; import { - COMPOSER_TOOLBAR_GEOMETRY, + composerToolbarGeometry, resolveComposerControlDensity, resolveComposerControlPresentation, - resolveComposerToolbarGlyphSize, + resolveModelTriggerLabels, } from "./layout"; +const POINTER = composerToolbarGeometry("pointer"); +const TOUCH = composerToolbarGeometry("touch"); + describe("composer control layout", () => { - it("removes labels in priority order as the toolbar narrows", () => { + it("removes labels, then folds controls, then drops the model label as the toolbar narrows", () => { expect(resolveComposerControlPresentation("full")).toEqual({ showCarets: true, showThinkingLabel: true, showModeLabel: true, aggregateFeatures: false, + foldControls: false, + showModelLabel: true, }); - expect(resolveComposerControlPresentation("condensed")).toEqual({ + expect(resolveComposerControlPresentation("condensed")).toMatchObject({ showCarets: false, showThinkingLabel: false, showModeLabel: true, aggregateFeatures: true, + foldControls: false, }); - expect(resolveComposerControlPresentation("tight")).toEqual({ - showCarets: false, - showThinkingLabel: false, + expect(resolveComposerControlPresentation("tight")).toMatchObject({ showModeLabel: false, - aggregateFeatures: true, + foldControls: false, + showModelLabel: true, + }); + expect(resolveComposerControlPresentation("compact")).toMatchObject({ + foldControls: true, + showModelLabel: true, + }); + expect(resolveComposerControlPresentation("iconOnly")).toMatchObject({ + foldControls: true, + showModelLabel: false, }); }); @@ -41,6 +54,7 @@ describe("composer control layout", () => { resolveComposerControlDensity({ availableWidth: 420, currentDensity: "full", + geometry: POINTER, controls, }), ).toBe("full"); @@ -48,6 +62,7 @@ describe("composer control layout", () => { resolveComposerControlDensity({ availableWidth: 380, currentDensity: "full", + geometry: POINTER, controls, }), ).toBe("condensed"); @@ -55,6 +70,7 @@ describe("composer control layout", () => { resolveComposerControlDensity({ availableWidth: 290, currentDensity: "condensed", + geometry: POINTER, controls, }), ).toBe("condensed"); @@ -62,6 +78,7 @@ describe("composer control layout", () => { resolveComposerControlDensity({ availableWidth: 280, currentDensity: "condensed", + geometry: POINTER, controls, }), ).toBe("tight"); @@ -69,6 +86,7 @@ describe("composer control layout", () => { resolveComposerControlDensity({ availableWidth: 300, currentDensity: "tight", + geometry: POINTER, controls, }), ).toBe("tight"); @@ -76,6 +94,7 @@ describe("composer control layout", () => { resolveComposerControlDensity({ availableWidth: 312, currentDensity: "tight", + geometry: POINTER, controls, }), ).toBe("condensed"); @@ -85,6 +104,7 @@ describe("composer control layout", () => { const base = { availableWidth: 430, currentDensity: "condensed" as const, + geometry: POINTER, }; expect( @@ -129,6 +149,7 @@ describe("composer control layout", () => { const base = { availableWidth: 430, currentDensity: "full" as const, + geometry: POINTER, controls: { hasModel: true, hasThinking: true, @@ -154,15 +175,166 @@ describe("composer control layout", () => { ).toBe("condensed"); }); - it("gives every toolbar control one shell and one platform glyph envelope", () => { - expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({ + it("gives every toolbar control one shell and one glyph per role and control sizing", () => { + const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; + expect(POINTER).toEqual({ controlSize: 28, + glyphs: { icon: pointerGlyph, label: pointerGlyph }, controlGap: 4, iconLabelGap: 4, labelPadding: 8, caretSize: 14, }); - expect(resolveComposerToolbarGlyphSize("web")).toBe(16); - expect(resolveComposerToolbarGlyphSize("native")).toBe(20); + expect(TOUCH).toMatchObject({ + controlSize: 20, + glyphs: { + icon: { size: 20, strokeWidth: 1.5, tone: "foreground" }, + label: { size: 20, strokeWidth: 1.5, tone: "foreground" }, + }, + controlGap: 20, + }); + }); + + it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => { + const input = { + availableWidth: 400, + currentDensity: "full" as const, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" as const }], + fontScale: 1, + }, + }; + + expect(resolveComposerControlDensity({ ...input, geometry: POINTER })).toBe("full"); + expect(resolveComposerControlDensity({ ...input, geometry: TOUCH })).toBe("condensed"); + }); + + describe("touch toolbar on an iPad in portrait, sidebar docked", () => { + // Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they + // paint, 20pt apart, so icon-only thinking, mode and features need 200. + const controls = { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" as const }, { type: "toggle" as const }], + fontScale: 1, + }; + + it("keeps the model family label with icon-only controls on an empty draft", () => { + expect( + resolveComposerControlDensity({ + availableWidth: 280, + currentDensity: "full", + geometry: TOUCH, + controls, + }), + ).toBe("tight"); + }); + + it("keeps the icon-only controls once an attachment brings in send", () => { + expect( + resolveComposerControlDensity({ + availableWidth: 240, + currentDensity: "tight", + geometry: TOUCH, + controls, + }), + ).toBe("tight"); + }); + + it("folds thinking, mode and features into the model control below their width", () => { + expect( + resolveComposerControlDensity({ + availableWidth: 180, + currentDensity: "tight", + geometry: TOUCH, + controls, + }), + ).toBe("compact"); + }); + + it("drops the model label only after the controls are folded", () => { + expect( + resolveComposerControlDensity({ + availableWidth: 64, + currentDensity: "compact", + geometry: TOUCH, + controls, + }), + ).toBe("iconOnly"); + }); + }); + + it("starts a compact composer at the folded toolbar however wide it is", () => { + const input = { + currentDensity: "compact" as const, + geometry: TOUCH, + minimumDensity: "compact" as const, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [], + fontScale: 1, + }, + }; + + expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact"); + // The thinking label is not budgeted: it gives way before the model family label does. + expect(resolveComposerControlDensity({ ...input, availableWidth: 96 })).toBe("compact"); + expect(resolveComposerControlDensity({ ...input, availableWidth: 64 })).toBe("iconOnly"); + }); +}); + +describe("folded model control labels", () => { + it("shows the thinking label while the model label fits beside it in full", () => { + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 191, fullWidth: 190 }, + }), + ).toBe("modelAndThinking"); + }); + + it("hides the thinking label when rounded widths tie", () => { + // On the web, 203.14 of labels and a 203 frame both report 203. + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 203, fullWidth: 203 }, + }), + ).toBe("model"); + }); + + it("hides the thinking label before the model label would truncate", () => { + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 189.5, fullWidth: 190 }, + }), + ).toBe("model"); + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 90, fullWidth: 190 }, + }), + ).toBe("model"); + }); + + it("decides nothing until both widths are measured", () => { + expect(resolveModelTriggerLabels({ hasThinking: true, measurements: {} })).toBe("measuring"); + expect( + resolveModelTriggerLabels({ hasThinking: true, measurements: { availableWidth: 300 } }), + ).toBe("measuring"); + expect(resolveModelTriggerLabels({ hasThinking: true, measurements: { fullWidth: 120 } })).toBe( + "measuring", + ); + }); + + it("needs no measurement without a thinking label", () => { + expect(resolveModelTriggerLabels({ hasThinking: false, measurements: {} })).toBe("model"); }); }); diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index 6f7c78cc73..ec6a5b8f26 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -1,4 +1,25 @@ -export type ComposerControlDensity = "full" | "condensed" | "tight"; +import { + TOOLBAR_CONTROL_GAPS, + TOOLBAR_CONTROL_SIZES, + toolbarGlyphsFor, + type ControlSizing, + type ToolbarGlyphs, +} from "@/components/ui/control-geometry"; + +/** + * How much of the toolbar shows, widest first. Each step drops something before the next: + * carets and labels, then the mode label, then the thinking, mode and feature controls fold into + * the model control's sheet (`compact`, the phone toolbar), then the model's own label. + */ +export type ComposerControlDensity = "full" | "condensed" | "tight" | "compact" | "iconOnly"; + +const DENSITY_LADDER: readonly ComposerControlDensity[] = [ + "full", + "condensed", + "tight", + "compact", + "iconOnly", +]; export interface ComposerControlPresence { hasModel: boolean; @@ -15,27 +36,53 @@ export interface ComposerControlPresentation { showThinkingLabel: boolean; showModeLabel: boolean; aggregateFeatures: boolean; + /** Thinking, mode and features live in the model control's sheet. */ + foldControls: boolean; + showModelLabel: boolean; } -export const COMPOSER_TOOLBAR_GEOMETRY = { - controlSize: 28, - controlGap: 4, - iconLabelGap: 4, - labelPadding: 8, - caretSize: 14, -} as const; +/** What each toolbar control takes up in the row, which is what the density budget sums. */ +export interface ComposerToolbarGeometry { + /** An icon-only control's width in the row. */ + controlSize: number; + glyphs: ToolbarGlyphs; + controlGap: number; + iconLabelGap: number; + /** A labelled control's padding on each side, as far as it takes room in the row. */ + labelPadding: number; + caretSize: number; +} + +/** + * The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a + * control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an + * icon-only control takes its glyph's width and a picker's padding takes no room. + */ +export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry { + const glyphs = toolbarGlyphsFor(sizing); + const isTouch = sizing === "touch"; + return { + controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer, + glyphs, + controlGap: TOOLBAR_CONTROL_GAPS[sizing], + iconLabelGap: 4, + labelPadding: isTouch ? 0 : 8, + caretSize: 14, + }; +} const DENSITY_HYSTERESIS = 12; +/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */ +const MODEL_FAMILY_LABEL_WIDTH = 56; function normalizedFontScale(fontScale: number): number { return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1; } -function sumControlWidths(widths: number[]): number { +function sumControlWidths(widths: number[], geometry: ComposerToolbarGeometry): number { if (widths.length === 0) return 0; return ( - widths.reduce((total, width) => total + width, 0) + - (widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap + widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * geometry.controlGap ); } @@ -43,92 +90,203 @@ function estimateLabelWidth(label: string, fontScale: number): number { return Array.from(label).length * 7 * fontScale; } +/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */ +function labelledChrome(geometry: ComposerToolbarGeometry): number { + return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap; +} + function resolveFeatureControlWidth( feature: ComposerFeatureControlPresence, fontScale: number, + geometry: ComposerToolbarGeometry, ): number { - if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize; + if (feature.type === "toggle") return geometry.controlSize; return ( - COMPOSER_TOOLBAR_GEOMETRY.controlSize + - COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap + - COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 + + geometry.controlSize + + geometry.iconLabelGap + + geometry.labelPadding * 2 + estimateLabelWidth(feature.label, fontScale) ); } -function resolveCondensedFloor(controls: ComposerControlPresence): number { +function resolveCondensedFloor( + controls: ComposerControlPresence, + geometry: ComposerToolbarGeometry, +): number { const fontScale = normalizedFontScale(controls.fontScale); + const chrome = labelledChrome(geometry); const widths: number[] = []; - if (controls.hasModel) widths.push(36 + 60 * fontScale); - if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); - if (controls.hasMode) widths.push(36 + 96 * fontScale); - if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); - return sumControlWidths(widths); + if (controls.hasModel) widths.push(chrome + 60 * fontScale); + if (controls.hasThinking) widths.push(geometry.controlSize); + if (controls.hasMode) widths.push(chrome + 96 * fontScale); + if (controls.features.length > 0) widths.push(geometry.controlSize); + return sumControlWidths(widths, geometry); } -function resolveFullFloor(controls: ComposerControlPresence): number { +function resolveFullFloor( + controls: ComposerControlPresence, + geometry: ComposerToolbarGeometry, +): number { const fontScale = normalizedFontScale(controls.fontScale); + const chrome = labelledChrome(geometry); + const caret = geometry.caretSize; const widths: number[] = []; - if (controls.hasModel) widths.push(50 + 70 * fontScale); - if (controls.hasThinking) widths.push(54 + 48 * fontScale); - if (controls.hasMode) widths.push(54 + 96 * fontScale); + if (controls.hasModel) widths.push(chrome + caret + 70 * fontScale); + if (controls.hasThinking) widths.push(chrome + geometry.iconLabelGap + caret + 48 * fontScale); + if (controls.hasMode) widths.push(chrome + geometry.iconLabelGap + caret + 96 * fontScale); for (const feature of controls.features) { - widths.push(resolveFeatureControlWidth(feature, fontScale)); + widths.push(resolveFeatureControlWidth(feature, fontScale, geometry)); } - return sumControlWidths(widths); + return sumControlWidths(widths, geometry); } +/** Model at its family label, with thinking, mode and features as icon-only squares. */ +function resolveTightFloor( + controls: ComposerControlPresence, + geometry: ComposerToolbarGeometry, +): number { + const fontScale = normalizedFontScale(controls.fontScale); + const widths: number[] = []; + if (controls.hasModel) { + widths.push(labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale); + } + if (controls.hasThinking) widths.push(geometry.controlSize); + if (controls.hasMode) widths.push(geometry.controlSize); + if (controls.features.length > 0) widths.push(geometry.controlSize); + return sumControlWidths(widths, geometry); +} + +/** + * One model control carrying the model family label. Its thinking label is not budgeted: it is + * the first label to go when the control runs out of room; see `resolveModelTriggerLabels`. + */ +function resolveCompactFloor( + controls: ComposerControlPresence, + geometry: ComposerToolbarGeometry, +): number { + const fontScale = normalizedFontScale(controls.fontScale); + return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale; +} + +function resolveDensityFloor( + density: ComposerControlDensity, + controls: ComposerControlPresence, + geometry: ComposerToolbarGeometry, +): number { + switch (density) { + case "full": + return resolveFullFloor(controls, geometry); + case "condensed": + return resolveCondensedFloor(controls, geometry); + case "tight": + return resolveTightFloor(controls, geometry); + case "compact": + return resolveCompactFloor(controls, geometry); + case "iconOnly": + return 0; + } +} + +/** + * The widest density whose floor fits `availableWidth`. Widening past the current density needs + * the floor plus the hysteresis and keeping it tolerates the floor minus it, so a width near a + * floor does not flip back and forth. `minimumDensity` is the widest density allowed. + */ export function resolveComposerControlDensity(input: { availableWidth: number; currentDensity: ComposerControlDensity; controls: ComposerControlPresence; + geometry: ComposerToolbarGeometry; + minimumDensity?: ComposerControlDensity; }): ComposerControlDensity { - const fullFloor = resolveFullFloor(input.controls); - const condensedFloor = resolveCondensedFloor(input.controls); - - if (input.currentDensity === "full") { - if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full"; - return input.availableWidth >= condensedFloor ? "condensed" : "tight"; + const current = DENSITY_LADDER.indexOf(input.currentDensity); + const first = DENSITY_LADDER.indexOf(input.minimumDensity ?? "full"); + for (let step = first; step < DENSITY_LADDER.length; step += 1) { + const density = DENSITY_LADDER[step]; + const floor = resolveDensityFloor(density, input.controls, input.geometry); + let required = floor; + if (step < current) required = floor + DENSITY_HYSTERESIS; + if (step === current) required = floor - DENSITY_HYSTERESIS; + if (input.availableWidth >= required) return density; } - - if (input.currentDensity === "condensed") { - if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full"; - if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight"; - return "condensed"; - } - - if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full"; - if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed"; - return "tight"; + return "iconOnly"; } -export function resolveComposerControlPresentation( - density: ComposerControlDensity, -): ComposerControlPresentation { - if (density === "full") { - return { - showCarets: true, - showThinkingLabel: true, - showModeLabel: true, - aggregateFeatures: false, - }; - } - if (density === "condensed") { - return { - showCarets: false, - showThinkingLabel: false, - showModeLabel: true, - aggregateFeatures: true, - }; - } - return { +const PRESENTATIONS: Record = { + full: { + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + foldControls: false, + showModelLabel: true, + }, + condensed: { + showCarets: false, + showThinkingLabel: false, + showModeLabel: true, + aggregateFeatures: true, + foldControls: false, + showModelLabel: true, + }, + tight: { showCarets: false, showThinkingLabel: false, showModeLabel: false, aggregateFeatures: true, - }; + foldControls: false, + showModelLabel: true, + }, + compact: { + showCarets: false, + showThinkingLabel: true, + showModeLabel: false, + aggregateFeatures: true, + foldControls: true, + showModelLabel: true, + }, + iconOnly: { + showCarets: false, + showThinkingLabel: false, + showModeLabel: false, + aggregateFeatures: true, + foldControls: true, + showModelLabel: false, + }, +}; + +export function resolveComposerControlPresentation( + density: ComposerControlDensity, +): ComposerControlPresentation { + return PRESENTATIONS[density]; } -export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number { - return platform === "native" ? 20 : 16; +/** + * Which labels the folded model control shows. `measuring` until every width is known, so the + * control never paints a choice made without them. + */ +export type ModelTriggerLabels = "measuring" | "modelAndThinking" | "model"; + +/** Widths measured from the rendered model control, in points. */ +export interface ModelTriggerMeasurements { + /** The width the control may take. */ + availableWidth: number; + /** The control with its model and thinking labels side by side, untruncated. */ + fullWidth: number; +} + +/** + * The thinking label goes first: it shows only while the model label fits beside it in full, so + * the model label truncates only once the thinking label is gone. The web reports layout widths + * rounded to whole pixels, so equal widths can hide a fraction of a pixel of overflow; only a + * full width strictly below the available width is known to fit. + */ +export function resolveModelTriggerLabels(input: { + hasThinking: boolean; + measurements: Partial; +}): ModelTriggerLabels { + if (!input.hasThinking) return "model"; + const { availableWidth, fullWidth } = input.measurements; + if (availableWidth === undefined || fullWidth === undefined) return "measuring"; + return fullWidth < availableWidth ? "modelAndThinking" : "model"; } diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 59fb515ce5..ce6b6d000a 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -27,6 +27,7 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import type { AgentMode } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getAgentModeIcon, getAgentModeOptionIcon } from "@/agent-controls/icons"; +import { DensityIcon } from "@/components/ui/density-icon"; interface ModeComboboxOptionProps { option: ComboboxOption; selected: boolean; @@ -34,7 +35,6 @@ interface ModeComboboxOptionProps { onPress: () => void; provider: string; providerDefinitions: AgentProviderDefinition[]; - iconColor: string; } function ModeComboboxOption({ @@ -44,12 +44,12 @@ function ModeComboboxOption({ onPress, provider, providerDefinitions, - iconColor, }: ModeComboboxOptionProps) { const IconComponent = getAgentModeOptionIcon(provider, option.id, providerDefinitions); const leadingSlot = useMemo( - () => (IconComponent ? : null), - [IconComponent, iconColor], + () => + IconComponent ? : null, + [IconComponent], ); return ( void }) { - const { theme } = useUnistyles(); const { presentation } = useComposerControlLayout(); const { t } = useTranslation(); const { isActiveComposer } = useComposerKeyboardScope(); @@ -102,7 +101,6 @@ export function AgentModeControl({ }, [modeOptions, selectedModeId]); const Icon = getAgentModeIcon(provider, selectedMode?.id ?? "", providerDefinitions); - const iconColor = theme.colors.foregroundMuted; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; const allOptions = useMemo( @@ -171,10 +169,9 @@ export function AgentModeControl({ onPress={args.onPress} provider={provider} providerDefinitions={providerDefinitions} - iconColor={theme.colors.foreground} /> ), - [provider, providerDefinitions, theme.colors.foreground], + [provider, providerDefinitions], ); const sheetHeader = useMemo( @@ -198,7 +195,6 @@ export function AgentModeControl({ (undefined); + const [fullWidth, setFullWidth] = useState<{ key: string; width: number } | null>(null); + + const onFrameLayout = useCallback((event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setAvailableWidth(width); + }, []); + const onFullLayout = useCallback( + (event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setFullWidth({ key: measuredKey, width }); + }, + [measuredKey], + ); + + const labels = resolveModelTriggerLabels({ + hasThinking: thinkingLabel !== null, + measurements: { + availableWidth, + fullWidth: fullWidth?.key === measuredKey ? fullWidth.width : undefined, + }, + }); + return { labels, measuredKey, onFrameLayout, onFullLayout }; +} + export function CompactModelSheet({ providers, selectedProvider, @@ -93,7 +148,8 @@ export function CompactModelSheet({ isRetryingProvider = false, disabled = false, serverId = null, - glyphSize, + glyphs, + showLabels, canSwitchProvider, children, }: CompactModelSheetProps) { @@ -229,15 +285,6 @@ export function CompactModelSheet({ open(); }, [close, isOpen, open]); - const triggerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.trigger, - hovered && styles.triggerHovered, - (pressed || isOpen) && styles.triggerPressed, - disabled && styles.triggerDisabled, - ], - [disabled, isOpen], - ); const mobileRootContent = useMemo( () => ( @@ -263,34 +310,19 @@ export function CompactModelSheet({ return ( <> - - {hasSelectedProvider ? ( - - - - ) : null} - - - {shortModelLabel(rootBrowser.triggerLabel)} - - {thinkingLabel ? ( - - {thinkingLabel} - - ) : null} - - + /> | null; + modelLabel: string; + thinkingLabel: string | null; + glyphs: ToolbarGlyphs; + iconOnly: boolean; + isOpen: boolean; + disabled: boolean; + onPress: () => void; + accessibilityLabel: string; +}) { + const glyphRole = iconOnly ? "icon" : "label"; + const glyph = glyphs[glyphRole]; + const triggerLabels = useModelTriggerLabels({ + modelLabel, + thinkingLabel: iconOnly ? null : thinkingLabel, + glyphSize: glyph.size, + }); + const providerIconColor = + glyph.tone === "foreground" ? styles.providerIconForeground.color : styles.providerIcon.color; + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.trigger, + iconOnly && styles.triggerIconOnly, + hovered && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + ], + [disabled, iconOnly, isOpen], + ); + + return ( + + + {ModelIcon ? ( + + + + ) : null} + {!iconOnly ? ( + + + {thinkingLabel && triggerLabels.labels !== "model" ? ( + + {thinkingLabel} + + ) : null} + + ) : null} + + {!iconOnly && thinkingLabel ? ( + + {ModelIcon ? {null} : null} + + + {modelLabel} + + + {thinkingLabel} + + + + ) : null} + + ); +} + const styles = StyleSheet.create((theme) => ({ trigger: { - height: 28, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, + triggerIconOnly: { + ...toolbarSlot(TOUCH_ICON_SIZE.md), + flexShrink: 0, + paddingHorizontal: 0, + justifyContent: "center", + }, triggerHovered: { backgroundColor: theme.colors.surface2, }, triggerPressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, triggerDisabled: { opacity: 0.5, @@ -407,6 +562,28 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1], }, + triggerLabelsMeasuring: { + opacity: 0, + }, + /** The space the trigger may take; the trigger itself stays as wide as its content. */ + triggerFrame: { + minWidth: 0, + flexGrow: 1, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + }, + /** An invisible copy of the trigger with both labels untruncated. */ + triggerMeasurer: { + position: "absolute", + left: 0, + top: 0, + flexShrink: 0, + opacity: 0, + }, + triggerMeasuredText: { + flexShrink: 0, + }, triggerThinking: { flexShrink: 0, color: theme.colors.foregroundExtraMuted, @@ -416,6 +593,9 @@ const styles = StyleSheet.create((theme) => ({ providerIcon: { color: theme.colors.foregroundMuted, }, + providerIconForeground: { + color: theme.colors.foreground, + }, sheetBody: { paddingHorizontal: 0, paddingTop: 0, diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 4b6407c872..ab26fe5612 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -19,15 +19,18 @@ import Animated, { import { useKeyboardShift } from "@/keyboard/shift"; import { resolveCenteredClearance, + resolveComposerBottomPlacement, resolveComposerSlotCapacity, updateComposerCapacity, type ComposerCapacity, } from "./internal/capacity"; +import { SPACING } from "@/styles/theme"; const ViewportSizing = createContext | null>(null); interface ComposerViewportProps extends ViewProps { bottomInset?: number; + keyboardGap?: number; centered?: boolean; } @@ -36,6 +39,7 @@ function ComposerViewport({ children, onLayout, bottomInset = 0, + keyboardGap = 0, centered = false, ...props }: ComposerViewportProps) { @@ -48,6 +52,7 @@ function ComposerViewport({ bottomInset, safeAreaBottom: safeAreaBottom.value, keyboardShift: layoutShift.value, + keyboardGap, centered, }), (geometry) => { @@ -129,6 +134,10 @@ export function ComposerDock({ const insets = useSafeAreaInsets(); const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); const centeredClearance = useCenteredClearance(insets.bottom); + const placement = resolveComposerBottomPlacement({ + safeAreaBottom: insets.bottom, + contentSpacing: SPACING[4], + }); // Preserve the existing centered form's visual balance on tablets. const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0; const fullscreenSurface = [ @@ -176,17 +185,23 @@ export function ComposerDock({ style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer} fullscreen={fullscreen} > - {composer} + + {composer} + - {overlay} + {overlay} ); } return ( - - + + {composer} - {overlay} + {overlay} ); } @@ -232,6 +247,7 @@ const dockStyles = StyleSheet.create({ centered: { flexShrink: 1, width: "100%" }, // Reserve the composer's own capped height before the setup scroll view shrinks. centeredComposer: { width: "100%", flexShrink: 0 }, + centeredComposerSurface: { width: "100%", flexShrink: 1 }, setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, hiddenSetup: { display: "none" }, }); diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index 421002352e..0456cd6280 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -72,7 +72,7 @@ export function ComposerDock({ const styles = StyleSheet.create((theme) => ({ surface: { flex: 1, overflow: "hidden" }, content: { flex: 1, justifyContent: "flex-end" }, - composer: { width: "100%", flexShrink: 1 }, + composer: { width: "100%", flexShrink: 1, paddingBottom: theme.spacing[4] }, // Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor // never reaches the zoomed visual viewport, while a fixed box chains to the viewport // directly. React Native Web renders `fixed`; React Native's style type omits it. diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index 44e79e6ef9..973c7f2406 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,18 +1,66 @@ import { describe, expect, it } from "vitest"; import { resolveCenteredClearance, + resolveComposerBottomPlacement, resolveComposerSlotCapacity, resolveInlineComposerCapacity, resolveComposerCapacity, updateComposerCapacity, } from "./capacity"; +describe("composer bottom placement", () => { + const keyboardHeight = 264; + /** Keyboard top minus composer bottom, both measured up from the window bottom. */ + function keyboardGapAbove(safeAreaBottom: number): number { + const placement = resolveComposerBottomPlacement({ safeAreaBottom, contentSpacing: 16 }); + const shift = keyboardHeight - safeAreaBottom; + const lift = shift + Math.min(placement.keyboardGap, shift); + return placement.restingPadding + lift - keyboardHeight; + } + + it("rests at the bottom clearance without stacking a second gutter", () => { + expect(resolveComposerBottomPlacement({ safeAreaBottom: 34, contentSpacing: 16 })).toEqual({ + restingPadding: 34, + keyboardGap: 16, + }); + expect( + resolveComposerBottomPlacement({ safeAreaBottom: 24, contentSpacing: 16 }).restingPadding, + ).toBe(24); + }); + + it.each([0, 20, 34])("never rests closer to the edge than the content spacing (%s)", (inset) => { + expect( + resolveComposerBottomPlacement({ safeAreaBottom: inset, contentSpacing: 16 }).restingPadding, + ).toBe(Math.max(inset, 16)); + }); + + it("keeps a measured zero clearance at the content spacing", () => { + expect(resolveComposerBottomPlacement({ safeAreaBottom: 0, contentSpacing: 16 })).toEqual({ + restingPadding: 16, + keyboardGap: 0, + }); + }); + + it.each([0, 20, 24, 34])("keeps the content spacing above the keyboard (%s)", (inset) => { + expect(keyboardGapAbove(inset)).toBe(16); + }); + + it("reserves the gap's extra rise out of the docked composer's capacity", () => { + const viewport = { height: 582, bottomInset: 0, safeAreaBottom: 34, centered: false }; + const closed = resolveComposerCapacity({ ...viewport, keyboardShift: 0, keyboardGap: 16 }); + const open = resolveComposerCapacity({ ...viewport, keyboardShift: 230, keyboardGap: 16 }); + expect(closed).toBe(577); + expect(open).toBe(331); + }); +}); + describe("composer viewport", () => { it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => { const open = updateComposerCapacity(undefined, { height: 582, bottomInset: 24, safeAreaBottom: 0, + keyboardGap: 0, centered: false, keyboardShift: 308, }); @@ -25,6 +73,7 @@ describe("composer viewport", () => { height: 582, bottomInset: 24, safeAreaBottom: 0, + keyboardGap: 0, centered: false, keyboardShift: 308, }); @@ -35,7 +84,13 @@ describe("composer viewport", () => { ); }); it("preserves the editing capacity when the keyboard closes", () => { - const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false }; + const viewport = { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + keyboardGap: 0, + centered: false, + }; const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }); expect(open.capacity).toBe(245); @@ -48,6 +103,7 @@ describe("composer viewport", () => { height: 582, bottomInset: 24, safeAreaBottom: 0, + keyboardGap: 0, centered: false, keyboardShift: 308, }); @@ -56,6 +112,7 @@ describe("composer viewport", () => { height: 650, bottomInset: 24, safeAreaBottom: 0, + keyboardGap: 0, centered: false, keyboardShift: 0, }).capacity, @@ -65,6 +122,7 @@ describe("composer viewport", () => { height: 0, bottomInset: 24, safeAreaBottom: 0, + keyboardGap: 0, centered: false, keyboardShift: 0, }), @@ -76,6 +134,7 @@ describe("composer viewport", () => { bottomInset: 24, safeAreaBottom: 0, keyboardShift: 308, + keyboardGap: 0, centered: false, }); expect(height).toBe(245); @@ -91,6 +150,7 @@ describe("composer viewport", () => { bottomInset, safeAreaBottom: 0, keyboardShift, + keyboardGap: 0, centered: true, }); expect(height).toBe(695); @@ -110,6 +170,7 @@ describe("composer viewport", () => { bottomInset: 72, safeAreaBottom: 0, keyboardShift: 313, + keyboardGap: 0, centered: true, }), ).toBe(407); @@ -122,6 +183,7 @@ describe("composer viewport", () => { bottomInset: 60, safeAreaBottom: 32, keyboardShift: 288, + keyboardGap: 0, centered: true, }; const height = resolveComposerCapacity(geometry); @@ -145,6 +207,7 @@ describe("composer viewport", () => { bottomInset: 24, safeAreaBottom: 0, keyboardShift: 0, + keyboardGap: 0, centered: false, }), ).toBe(553); @@ -154,6 +217,7 @@ describe("composer viewport", () => { bottomInset: 0, safeAreaBottom: 0, keyboardShift: 200, + keyboardGap: 0, centered: false, }), ).toBe(95); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts index 9c460e1729..d9be8b9255 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -4,6 +4,8 @@ interface ComposerGeometry { /** The keyboard also covers this space below its shift. */ safeAreaBottom: number; keyboardShift: number; + /** A docked composer rises this much beyond the keyboard shift; see the placement. */ + keyboardGap: number; centered: boolean; } @@ -24,10 +26,36 @@ export function resolveComposerCapacity(input: ComposerGeometry): number { input.height - input.bottomInset, input.height - input.safeAreaBottom - input.keyboardShift, ) - : input.height - input.bottomInset - input.keyboardShift; + : input.height - + input.bottomInset - + input.keyboardShift - + Math.min(input.keyboardGap, input.keyboardShift); return Math.max(0, available - 5); } +export interface ComposerBottomPlacement { + /** The composer's distance from the window bottom with the keyboard closed. */ + restingPadding: number; + /** How much further than the keyboard's safe-area overlap the composer rises. */ + keyboardGap: number; +} + +/** + * One bottom distance for the docked composer: the safe-area bottom, never closer to + * the edge than the content spacing. With the keyboard open it keeps the content + * spacing above the keyboard. + */ +export function resolveComposerBottomPlacement(input: { + safeAreaBottom: number; + contentSpacing: number; +}): ComposerBottomPlacement { + const restingPadding = Math.max(input.safeAreaBottom, input.contentSpacing); + return { + restingPadding, + keyboardGap: input.safeAreaBottom + input.contentSpacing - restingPadding, + }; +} + /** Space below a centered form that the keyboard fills before the form moves. */ export function resolveCenteredClearance(input: { viewportHeight: number; 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/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 8587fa3db6..fd8c8abb3c 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -60,17 +60,6 @@ describe("useDraftAgentCreateFlow", () => { expect(result.current.isSubmitting).toBe(true); expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); - expect(result.current.submittedStreamItems).toEqual([ - { - kind: "user_message", - id: "msg-prepared", - clientMessageId: "msg-prepared", - text: "build this", - timestamp: attempt.timestamp, - images: [image], - attachments: [attachment], - }, - ]); expect(createRequest).not.toHaveBeenCalled(); await act(async () => { diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index bd4d0fe95c..28ab74d145 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -8,15 +8,8 @@ import { import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; -import { - createUserMessage, - type StreamItem, - type UserMessageImageAttachment, -} from "@/types/stream"; +import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; -import type { PendingMessageSubmission } from "@/composer/submission/model"; - -const EMPTY_STREAM_ITEMS: StreamItem[] = []; interface CreateAttempt { clientMessageId: string; @@ -153,38 +146,6 @@ export function useDraftAgentCreateFlow({ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const submittedStreamItems = useMemo(() => { - if (machine.tag !== "creating") { - return EMPTY_STREAM_ITEMS; - } - - if ( - !machine.attempt.text && - (!machine.attempt.images || machine.attempt.images.length === 0) && - (!machine.attempt.attachments || machine.attempt.attachments.length === 0) - ) { - return EMPTY_STREAM_ITEMS; - } - - return [ - createUserMessage({ - clientMessageId: machine.attempt.clientMessageId, - text: machine.attempt.text, - timestamp: machine.attempt.timestamp, - images: machine.attempt.images, - attachments: machine.attempt.attachments, - }), - ]; - }, [machine]); - const pendingMessageSubmissions = useMemo(() => { - if (machine.tag !== "creating") return []; - return [ - { - clientMessageId: machine.attempt.clientMessageId, - }, - ]; - }, [machine]); - const draftAgent = machine.tag === "creating" ? machine.draftAgent : null; const startCreateAttempt = useCallback( (attempt: CreateAttempt) => { @@ -365,8 +326,6 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, 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/timeline.test.ts b/packages/app/src/composer/draft/timeline.test.ts new file mode 100644 index 0000000000..a1344f8830 --- /dev/null +++ b/packages/app/src/composer/draft/timeline.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PendingCreateAttempt } from "@/stores/create-flow-store"; +import type { UserMessageImageAttachment } from "@/types/stream"; +import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline"; + +const image: UserMessageImageAttachment = { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-key", + createdAt: 123, +}; +const attachment = { + type: "review", + cwd: "/repo", + summary: "Review", +} as unknown as AgentAttachment; + +function pendingAttempt(overrides: Partial = {}): PendingCreateAttempt { + return { + draftId: "draft-1", + serverId: "server-1", + agentId: null, + clientMessageId: "draft-1:initial-message", + text: "build this", + timestamp: Date.parse("2026-05-25T00:00:00.000Z"), + lifecycle: "active", + images: [image], + attachments: [attachment], + ...overrides, + }; +} + +describe("resolveDraftTimeline", () => { + it("shows the sent prompt pending with the turn working", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt(), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.agent).toEqual({ + serverId: "server-1", + id: "tab-1", + status: "running", + cwd: "/repo", + capabilities: DRAFT_CAPABILITIES, + }); + expect(timeline?.streamItems).toEqual([ + { + kind: "user_message", + id: "draft-1:initial-message", + clientMessageId: "draft-1:initial-message", + text: "build this", + timestamp: new Date("2026-05-25T00:00:00.000Z"), + images: [image], + attachments: [attachment], + }, + ]); + expect(timeline?.pendingMessageSubmissions).toEqual([ + { clientMessageId: "draft-1:initial-message" }, + ]); + expect(timeline?.turnPresentation.isActive).toBe(true); + }); + + it("keeps the prompt once the agent is created, until the tab retargets", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.streamItems).toHaveLength(1); + }); + + it("shows only the working turn for a prompt without content", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.streamItems).toEqual([]); + expect(timeline?.turnPresentation.isActive).toBe(true); + }); + + it("shows no timeline before a prompt is sent, after it fails, or on another host", () => { + const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" }; + + expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull(); + expect( + resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }), + ).toBeNull(); + expect( + resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }), + ).toBeNull(); + expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/draft/timeline.ts b/packages/app/src/composer/draft/timeline.ts new file mode 100644 index 0000000000..bbc0aecbce --- /dev/null +++ b/packages/app/src/composer/draft/timeline.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { + resolveTurnPresentation, + TURN_LIVENESS_IDLE, + type TurnPresentation, +} from "@/timeline/turn-liveness"; +import { createUserMessage, type StreamItem } from "@/types/stream"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; +import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core"; + +export const DRAFT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +}; + +export interface DraftConversation { + tabId: string; + workspaceId: string; + draftId: string; + setup?: WorkspaceDraftTabSetup; +} + +/** The timeline a draft shows while its first prompt creates the agent. */ +export interface DraftTimeline { + agent: AgentScreenAgent; + streamItems: StreamItem[]; + pendingMessageSubmissions: readonly PendingMessageSubmission[]; + turnPresentation: TurnPresentation; +} + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +/** + * The sent prompt, pending, with the turn working. The created agent's timeline carries the same + * row (same client message id), so the handoff only changes who owns it. + */ +export function resolveDraftTimeline(input: { + pending: PendingCreateAttempt | undefined; + serverId: string; + tabId: string; + cwd: string | null; +}): DraftTimeline | null { + const { pending, serverId, tabId, cwd } = input; + if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) { + return null; + } + const hasContent = + pending.text.length > 0 || + (pending.images?.length ?? 0) > 0 || + (pending.attachments?.length ?? 0) > 0; + return { + agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES }, + streamItems: hasContent + ? [ + createUserMessage({ + clientMessageId: pending.clientMessageId, + text: pending.text, + timestamp: new Date(pending.timestamp), + images: pending.images, + attachments: pending.attachments, + }), + ] + : EMPTY_STREAM_ITEMS, + pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }], + turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true), + }; +} + +export function useDraftTimeline(input: { + serverId: string; + draft: DraftConversation | null; +}): DraftTimeline | null { + const { serverId, draft } = input; + const pending = useCreateFlowStore((state) => + draft ? state.pendingByDraftId[draft.draftId] : undefined, + ); + const workspaceDirectory = useWorkspaceFields( + serverId, + draft?.workspaceId ?? null, + (workspace) => workspace.workspaceDirectory, + ); + const cwd = draft + ? resolveDraftWorkingDirectory({ + workspaceDirectory: workspaceDirectory || null, + initialSetup: draft.setup ?? null, + }) + : null; + const tabId = draft?.tabId ?? ""; + return useMemo( + () => resolveDraftTimeline({ pending, serverId, tabId, cwd }), + [cwd, pending, serverId, tabId], + ); +} diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 26bf548064..89211571a2 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -1,4 +1,5 @@ import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface WorkspaceDraftAutoSubmitConfig { provider: string; @@ -50,3 +51,13 @@ export function validateDraftSubmission(input: { }); return readiness.ok ? null : (readiness.reason ?? null); } + +export function resolveDraftWorkingDirectory(input: { + workspaceDirectory: string | null; + initialSetup: WorkspaceDraftTabSetup | null; +}): string | null { + if (input.initialSetup) { + return input.initialSetup.cwd; + } + return input.workspaceDirectory; +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d00b6f7790..2b99ac8c09 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,21 +1,17 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; +import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; -import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; -import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles"; -import { AgentStreamView } from "@/agent-stream/view"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; 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, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; @@ -25,13 +21,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; import { encodeImages } from "@/utils/encode-images"; -import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { + resolveDraftWorkingDirectory, shouldAllowEmptyDraftText, validateDraftSubmission, } from "@/composer/draft/workspace-tab-core"; -import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; @@ -41,7 +37,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, @@ -50,16 +46,6 @@ import { import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; -const EMPTY_PENDING_PERMISSIONS = new Map(); -const DRAFT_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - interface AutoSubmitConfig { provider: string; modeId: string | null; @@ -260,25 +246,15 @@ function buildDraftInitialValues(input: { }; } -function resolveDraftWorkingDirectory(input: { - workspaceDirectory: string | null; - initialSetup: WorkspaceDraftTabSetup | null; -}): string | null { - if (input.initialSetup) { - return input.initialSetup.cwd; - } - return input.workspaceDirectory; -} - -interface WorkspaceDraftAgentTabProps { +interface WorkspaceDraftComposerProps { serverId: string; workspaceId: string; tabId: string; draftId: string; initialSetup?: WorkspaceDraftTabSetup; isPaneFocused: boolean; + editingSession: ComposerEditingSession; onCreated: (snapshot: AgentSnapshotPayload) => void; - onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void; onOpenImportSheet?: () => void; } @@ -292,17 +268,21 @@ function resolveImportPillPress( return onOpenImportSheet ?? null; } -export function WorkspaceDraftAgentTab({ +/** + * Creates the agent from the draft's first prompt. The conversation panel shows that prompt in + * the timeline from the create-flow store while the agent is created. + */ +export function WorkspaceDraftComposer({ serverId, workspaceId, tabId, draftId, initialSetup = undefined, isPaneFocused, + editingSession, onCreated, - onOpenWorkspaceFile, onOpenImportSheet, -}: WorkspaceDraftAgentTabProps) { +}: WorkspaceDraftComposerProps) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -387,10 +367,9 @@ export function WorkspaceDraftAgentTab({ }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); - 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({ @@ -421,82 +400,71 @@ export function WorkspaceDraftAgentTab({ [isCompactFormFactor, openInSidePane, serverId, workspaceId], ); - const { - formErrorMessage, - isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, - draftAgent, - handleCreateFromInput, - continueCreateFromAttempt, - } = useDraftAgentCreateFlow({ - draftId, - getPendingServerId: () => serverId, - initialAttempt: initialCreateAttempt, - allowEmptyText: allowsEmptyAutoSubmit, - validateBeforeSubmit: ({ text, attachments }) => { - const allowsEmptyDraftText = shouldAllowEmptyDraftText({ - allowsEmptyAutoSubmit, - attachments, - }); - return validateDraftSubmission({ - text, - allowsEmptyAutoSubmit: allowsEmptyDraftText, - composerState, - autoSubmitConfig, - workspaceDirectory: draftWorkingDirectory, - isConnected, - }); - }, - onBeforeSubmit: async () => { - await composerState.persistFormPreferences(); - if (isWeb) { - (document.activeElement as HTMLElement | null)?.blur?.(); - } - Keyboard.dismiss(); - }, - buildDraftAgent: (attempt) => - buildDraftAgentSnapshot({ - attempt, - serverId, - tabId, - workspaceDirectory: draftWorkingDirectory, - autoSubmitConfig, - composerState, - selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => { - if (pendingAutoSubmit?.agentCreation) { - const result = await pendingAutoSubmit.agentCreation.result; - return { agentId: result.id, result }; - } - return submitDraftCreateRequest({ - draftId, - attempt, - text, - images, - attachments, - cwd, - client, - workspaceDirectory: draftWorkingDirectory, - workspaceId: workspaceFields?.id ?? null, - autoSubmitConfig, - composerState, - hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), - selectModelMessage: t("workspaceSetup.errors.selectModel"), - }); - }, - onCreateSuccess: ({ result }) => { - clearDraftInput("sent"); - clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); - useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); - onCreated(result); - }, - }); - const turnPresentation = useMemo( - () => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0), - [pendingMessageSubmissions], - ); + const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } = + useDraftAgentCreateFlow({ + draftId, + getPendingServerId: () => serverId, + initialAttempt: initialCreateAttempt, + allowEmptyText: allowsEmptyAutoSubmit, + validateBeforeSubmit: ({ text, attachments }) => { + const allowsEmptyDraftText = shouldAllowEmptyDraftText({ + allowsEmptyAutoSubmit, + attachments, + }); + return validateDraftSubmission({ + text, + allowsEmptyAutoSubmit: allowsEmptyDraftText, + composerState, + autoSubmitConfig, + workspaceDirectory: draftWorkingDirectory, + isConnected, + }); + }, + onBeforeSubmit: async () => { + await composerState.persistFormPreferences(); + if (isWeb) { + (document.activeElement as HTMLElement | null)?.blur?.(); + } + Keyboard.dismiss(); + }, + buildDraftAgent: (attempt) => + buildDraftAgentSnapshot({ + attempt, + serverId, + tabId, + workspaceDirectory: draftWorkingDirectory, + autoSubmitConfig, + composerState, + selectModelMessage: t("workspaceSetup.errors.selectModel"), + }), + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, + attempt, + text, + images, + attachments, + cwd, + client, + workspaceDirectory: draftWorkingDirectory, + workspaceId: workspaceFields?.id ?? null, + autoSubmitConfig, + composerState, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + selectModelMessage: t("workspaceSetup.errors.selectModel"), + }); + }, + onCreateSuccess: ({ result }) => { + clearDraftInput("sent"); + clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); + onCreated(result); + }, + }); useAgentControlCommandCenterActions({ sourceId: `draft:${serverId}:${tabId}`, enabled: isPaneFocused && !isSubmitting, @@ -600,75 +568,48 @@ export function WorkspaceDraftAgentTab({ }), [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); - const dockContent = ( - - {isSubmitting && draftAgent ? ( - - - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - ); - return ( - - - {dockContent} - - {importPillPress && !isComposerFullscreen ? ( - - - - - - ) : null} - + + {formErrorMessage ? ( + + + {formErrorMessage} + - - + ) : null} + {importPillPress && !isComposerFullscreen ? ( + + + + + + ) : null} + + ); } @@ -682,27 +623,10 @@ const animatedStaticStyles = RNStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, + formErrorRow: { width: "100%", - backgroundColor: theme.colors.surface0, - }, - contentContainer: { - flex: 1, - }, - streamContainer: { - flex: 1, - }, - scrollView: { - flex: 1, - }, - configScrollContent: { paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[6], - }, - configSection: { - gap: theme.spacing[3], + alignItems: "center", }, importPillRow: { width: "100%", @@ -722,8 +646,10 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: theme.contentMaxWidth, flexDirection: "row", }, - errorContainer: { - marginTop: theme.spacing[2], + formError: { + width: "100%", + maxWidth: theme.contentMaxWidth, + marginBottom: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, 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 4776b5af3d..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; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d000c269af..60b05be23e 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -31,8 +31,20 @@ import { import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { + controlSizingForLayout, + TOOLBAR_CONTROL_GAP, + TOOLBAR_DISC_GLYPH_SIZES, + TOOLBAR_DISC_SIZE, + TOOLBAR_RINGS, + toolbarGlyphsFor, + toolbarGlyphTone, + type ToolbarGlyph, + toolbarSlot, +} from "@/components/ui/control-geometry"; +import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails"; import { usePanelStore } from "@/stores/panel-store"; -import { useHasFinePointer } from "@/hooks/use-fine-pointer"; +import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { useShallow } from "zustand/shallow"; import { ArrowUp, @@ -71,7 +83,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 { @@ -193,10 +205,6 @@ const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = []; function noop() {} const noopCallback = () => {}; -function resolveComposerButtonIconSize(): number { - return isWeb ? ICON_SIZE.md : ICON_SIZE.lg; -} - function resolveIsComposerLocked( submitBehavior: "clear" | "preserve-and-lock", isSubmitLoading: boolean, @@ -258,20 +266,22 @@ function resolveCheckoutRemoteUrl( return checkoutStatus?.remoteUrl ?? null; } -function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { +function buildCancelButtonStyle( + pressed: boolean, + isConnected: boolean, + isCancellingAgent: boolean, +): object[] { + const pressedStyle = pressed ? styles.discPressed : undefined; const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; - return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); + return [styles.cancelButton, pressedStyle, disabled].filter((value): value is object => + Boolean(value), + ); } -function buildRealtimeVoiceButtonStyle( - hovered: boolean | undefined, - voiceButtonDisabled: boolean, - reserveLeadingSpace: boolean, -): object[] { - const hoveredStyle = hovered ? styles.iconButtonHovered : undefined; +function buildRealtimeVoiceButtonStyle(pressed: boolean, voiceButtonDisabled: boolean): object[] { + const pressedStyle = pressed ? styles.discPressed : undefined; const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined; - const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined; - return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter( + return [styles.realtimeVoiceButton, pressedStyle, disabledStyle].filter( (value): value is object => Boolean(value), ); } @@ -1048,10 +1058,7 @@ function ComposerAutocompleteBinding({ options={autocomplete.options} selectedIndex={autocomplete.selectedIndex} onSelect={onSelect} - isLoading={autocomplete.isLoading} - errorMessage={autocomplete.errorMessage} - loadingText={autocomplete.loadingText} - emptyText={autocomplete.emptyText} + status={autocomplete.status} > {children} @@ -1080,8 +1087,8 @@ function ComposerForgeBinding({ } interface ComposerCancelButtonProps { - buttonIconSize: number; - cancelButtonStyle: (object | undefined)[]; + discGlyphSize: number; + cancelButtonStyle: (state: PressableStateCallbackType) => object[]; handleCancelAgent: () => void; isConnected: boolean; isCancellingAgent: boolean; @@ -1090,7 +1097,7 @@ interface ComposerCancelButtonProps { } function ComposerCancelButton({ - buttonIconSize, + discGlyphSize, cancelButtonStyle, handleCancelAgent, isConnected, @@ -1104,7 +1111,7 @@ function ComposerCancelButton({ const icon = isCancellingAgent ? ( ) : ( - + ); const shortcutNode = agentInterruptKeys ? : null; return ( @@ -1116,7 +1123,7 @@ function ComposerCancelButton({ accessibilityRole="button" style={cancelButtonStyle} > - {icon} + {icon} @@ -1129,7 +1136,7 @@ function ComposerCancelButton({ } interface ComposerVoiceModeButtonProps { - buttonIconSize: number; + glyph: ToolbarGlyph; handleToggleRealtimeVoice: () => void; isConnected: boolean; isVoiceSwitching: boolean; @@ -1140,37 +1147,25 @@ interface ComposerVoiceModeButtonProps { t: TFunction; } -interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { +function showsVoiceModeButton(input: { isVoiceModeForAgent: boolean; hasAgent: boolean; isAgentRunning: boolean; hasSendableContent: boolean; - isCompact: boolean; showVoice: boolean; -} - -function ComposerRightControlsSlot({ - isVoiceModeForAgent, - hasAgent, - isAgentRunning, - hasSendableContent, - isCompact, - showVoice, - ...voiceProps -}: ComposerRightControlsSlotProps) { - const hideVoiceForCompactInput = isCompact && hasSendableContent; - const showVoiceModeButton = - showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; - if (!showVoiceModeButton) return null; +}): boolean { + // Voice mode holds the trailing disc until there is something to send, then Send takes it. return ( - - - + input.showVoice && + !input.isVoiceModeForAgent && + input.hasAgent && + !input.isAgentRunning && + !input.hasSendableContent ); } function ComposerVoiceModeButton({ - buttonIconSize, + glyph, handleToggleRealtimeVoice, isConnected, isVoiceSwitching, @@ -1181,13 +1176,25 @@ function ComposerVoiceModeButton({ const shortcutNode = voiceToggleKeys ? : null; const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { - if (isVoiceSwitching) { - return ; - } - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; - return ; + const colorMapping = + toolbarGlyphTone(glyph, Boolean(hovered)) === "foreground" + ? iconForegroundMapping + : iconForegroundMutedMapping; + return ( + + {isVoiceSwitching ? ( + + ) : ( + + )} + + ); }, - [buttonIconSize, isVoiceSwitching], + [glyph, isVoiceSwitching], ); return ( @@ -1281,7 +1288,6 @@ function ComposerContentImpl({ }: ComposerContentProps) { const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); - const buttonIconSize = resolveComposerButtonIconSize(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); @@ -1310,8 +1316,11 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); + const controlSizing = controlSizingForLayout(isCompactFormFactor); + const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon; + const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing]; const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); - const hasFinePointer = useHasFinePointer(); + const { finePointer: hasFinePointer } = usePointerCapabilities(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); const hasText = useSyncExternalStore( @@ -2010,8 +2019,9 @@ function ComposerContentImpl({ [agentId, recall, replaceUserInput, serverId], ); - const cancelButtonStyle = useMemo( - () => buildCancelButtonStyle(isConnected, isCancellingAgent), + const cancelButtonStyle = useCallback( + (state: PressableStateCallbackType) => + buildCancelButtonStyle(state.pressed, isConnected, isCancellingAgent), [isConnected, isCancellingAgent], ); @@ -2019,14 +2029,14 @@ function ComposerContentImpl({ const voiceButtonDisabled = !isConnected || isVoiceSwitching; const realtimeVoiceButtonStyle = useCallback( (state: PressableStateCallbackType & { hovered?: boolean }) => - buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout), - [isCompactLayout, voiceButtonDisabled], + buildRealtimeVoiceButtonStyle(state.pressed, voiceButtonDisabled), + [voiceButtonDisabled], ); const activeActionContent = useMemo( () => ( ( - - ), + const voiceModeContent = useMemo( + () => + showsVoiceModeButton({ + isVoiceModeForAgent, + hasAgent, + isAgentRunning, + hasSendableContent, + showVoice: mode.showVoice, + }) ? ( + + + + ) : null, [ - buttonIconSize, + toolbarGlyph, handleToggleRealtimeVoice, hasAgent, hasSendableContent, isAgentRunning, isConnected, - isCompactLayout, isVoiceModeForAgent, isVoiceSwitching, mode.showVoice, @@ -2081,7 +2093,6 @@ function ComposerContentImpl({ ], ); - const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; const beforeVoiceContent = useMemo( () => hasAgent ? ( @@ -2092,7 +2103,7 @@ function ComposerContentImpl({ maxTokens={agentState.contextWindowMaxTokens} usedTokens={agentState.contextWindowUsedTokens} totalCostUsd={agentState.totalCostUsd} - glyphSize={contextWindowMeterGlyphSize} + toolbarRing={TOOLBAR_RINGS[controlSizing]} /> ) : null, @@ -2103,7 +2114,7 @@ function ComposerContentImpl({ agentState.contextWindowMaxTokens, agentState.contextWindowUsedTokens, agentState.totalCostUsd, - contextWindowMeterGlyphSize, + controlSizing, ], ); @@ -2489,12 +2500,13 @@ function ComposerContentImpl({ disabled={isSubmitLoading} leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} - rightContent={rightContent} + voiceModeContent={voiceModeContent} activeActionContent={activeActionContent} voiceServerId={serverId} voiceAgentId={agentId} isAgentRunning={isAgentRunning} defaultSendBehavior={activeSendBehavior} + sendShortcut={appSettings.sendShortcut} onQueue={handleQueue} onSubmitLoadingPress={submitLoadingPressHandler} onKeyPress={handleCommandKeyPress} @@ -2567,7 +2579,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ width: "100%", overflow: "visible", paddingHorizontal: theme.spacing[4], - paddingBottom: theme.spacing[4], }, inputAreaLocked: { opacity: 0.6, @@ -2577,7 +2588,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ minHeight: 0, alignItems: "stretch", paddingHorizontal: 0, - paddingBottom: 0, }, fullscreenFill: { flex: 1, @@ -2597,42 +2607,49 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[3], }, cancelButton: { - width: 28, - height: 28, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), + alignItems: "center", + justifyContent: "center", + }, + cancelButtonDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[600], alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, rightControls: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, }, contextWindowMeterSlot: { - width: 28, - height: 28, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring), flexShrink: 0, alignItems: "center", justifyContent: "center", }, - realtimeVoiceButton: { - width: 28, - height: 28, + voiceModeDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, alignItems: "center", justifyContent: "center", }, - realtimeVoiceButtonCompactReserve: { - marginLeft: theme.spacing[1], + realtimeVoiceButton: { + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", }, realtimeVoiceButtonActive: { backgroundColor: theme.colors.palette.green[600], borderColor: theme.colors.palette.green[800], }, - iconButtonHovered: { - backgroundColor: theme.colors.surface2, + discPressed: { + opacity: 0.7, }, attachmentTray: { flexDirection: "row", diff --git a/packages/app/src/composer/input/fullscreen-exit.browser.test.tsx b/packages/app/src/composer/input/fullscreen-exit.browser.test.tsx new file mode 100644 index 0000000000..c9c62d85be --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.browser.test.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { dispatchTopOverlayKeyDown } from "@/keyboard"; +import { useComposerFullscreenExit } from "./fullscreen-exit"; + +it("the native entry claims Escape until fullscreen closes", () => { + let exits = 0; + function Harness({ active }: { active: boolean }) { + useComposerFullscreenExit(active, () => exits++); + return null; + } + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + flushSync(() => root.render()); + expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(true); + expect(exits).toBe(1); + flushSync(() => root.render()); + expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(false); + expect(exits).toBe(1); + } finally { + root.unmount(); + container.remove(); + } +}); diff --git a/packages/app/src/composer/input/fullscreen-exit.ts b/packages/app/src/composer/input/fullscreen-exit.ts index b76eeed5fc..b0fbe9e2a5 100644 --- a/packages/app/src/composer/input/fullscreen-exit.ts +++ b/packages/app/src/composer/input/fullscreen-exit.ts @@ -1,4 +1,6 @@ -import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; +import { useComposerHardwareBackExit } from "./hardware-back-exit"; +import { useOverlayRegistration } from "@/keyboard"; +import { useOverlayLayer } from "@/lib/overlay-root"; /** * Escape leaves fullscreen before app shortcuts see it. The registration only claims @@ -8,8 +10,10 @@ export function useComposerFullscreenExit( active: boolean, onExit: () => void, ): ((node: unknown) => void) | undefined { + useComposerHardwareBackExit(active, onExit); const layer = useOverlayLayer("modal"); - return useWebOverlayRegistration({ + return useOverlayRegistration({ + keys: ["Escape"], active, layer, manageFocus: false, diff --git a/packages/app/src/composer/input/fullscreen-exit.native.ts b/packages/app/src/composer/input/hardware-back-exit.android.ts similarity index 72% rename from packages/app/src/composer/input/fullscreen-exit.native.ts rename to packages/app/src/composer/input/hardware-back-exit.android.ts index c2344d50b0..96eec89bd6 100644 --- a/packages/app/src/composer/input/fullscreen-exit.native.ts +++ b/packages/app/src/composer/input/hardware-back-exit.android.ts @@ -2,10 +2,7 @@ import { useEffect } from "react"; import { BackHandler } from "react-native"; /** The hardware back button leaves fullscreen instead of the screen. */ -export function useComposerFullscreenExit( - active: boolean, - onExit: () => void, -): ((node: unknown) => void) | undefined { +export function useComposerHardwareBackExit(active: boolean, onExit: () => void): void { useEffect(() => { if (!active) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -14,5 +11,4 @@ export function useComposerFullscreenExit( }); return () => subscription.remove(); }, [active, onExit]); - return undefined; } diff --git a/packages/app/src/composer/input/hardware-back-exit.ts b/packages/app/src/composer/input/hardware-back-exit.ts new file mode 100644 index 0000000000..0fefbbad72 --- /dev/null +++ b/packages/app/src/composer/input/hardware-back-exit.ts @@ -0,0 +1 @@ +export function useComposerHardwareBackExit(_active: boolean, _onExit: () => void): void {} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73c54331d1..3c4541181f 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,5 @@ +import { useKeyboardFocusSurface } from "@/keyboard"; +import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, useComposerFullscreen, @@ -6,6 +8,7 @@ import { import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, + Platform, Text, useWindowDimensions, NativeSyntheticEvent, @@ -25,11 +28,31 @@ import { } from "react"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; +import { + controlSizingForLayout, + TOOLBAR_CONTROL_GAP, + TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_GLYPH_SIZES, + TOOLBAR_DISC_SIZE, + toolbarGlyphsFor, + toolbarGlyphTone, + type ToolbarGlyph, + toolbarSlot, +} from "@/components/ui/control-geometry"; +import { + COMPOSER_PADDING_BOTTOM, + COMPOSER_PADDING_HORIZONTAL, + COMPOSER_CORNER_RADIUS, + COMPOSER_PADDING_TOP, + COMPOSER_TOOLBAR_SHAPES, + composerToolbarOverhang, + resolveComposerLeadingEdge, +} from "@/composer/input/toolbar-rails"; 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 { hasFinePointer } from "@/hooks/pointer-capabilities"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -88,6 +111,7 @@ import { resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveMessageInputEnterAction, resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, @@ -96,6 +120,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 { @@ -155,8 +184,8 @@ export interface MessageInputProps { leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; - /** Auxiliary content to render on the right side after the voice button. */ - rightContent?: React.ReactNode; + /** The realtime voice mode button after the dictation button, or null when the row hides it. */ + voiceModeContent?: React.ReactNode; /** Primary action to render when the agent is active and the composer has no sendable content. */ activeActionContent?: React.ReactNode; voiceServerId?: string; @@ -167,6 +196,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. */ @@ -236,19 +266,28 @@ interface TextAreaHandle { }; } +function glyphColorMapping(glyph: ToolbarGlyph, hovered: boolean) { + return toolbarGlyphTone(glyph, hovered) === "foreground" + ? iconForegroundMapping + : iconForegroundMutedMapping; +} + function AttachButtonIcon({ hovered, onAttachButtonRef, - buttonIconSize, + glyph, }: { hovered: boolean; onAttachButtonRef: ((node: View | null) => void) | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; }) { - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ( - + ); } @@ -326,21 +365,25 @@ function VoiceButtonIcon({ hovered, isDictating, isMutedRealtime, - buttonIconSize, + glyph, + discGlyphSize, }: { hovered: boolean; isDictating: boolean; isMutedRealtime: boolean; - buttonIconSize: number; + glyph: ToolbarGlyph; + discGlyphSize: number; }) { if (isDictating) { - return ; + return ( + + + + ); } - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; - if (isMutedRealtime) { - return ; - } - return ; + const colorMapping = glyphColorMapping(glyph, hovered); + const Icon = isMutedRealtime ? ThemedMicOff : ThemedMic; + return ; } type ShortcutChord = NonNullable["chord"]>; @@ -379,12 +422,12 @@ function SendButtonContent({ isSubmitLoading, submitIcon, submitLabel, - buttonIconSize, + glyph, }: { isSubmitLoading: boolean; submitIcon: "arrow" | "return"; submitLabel: string | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; }) { if (isSubmitLoading) { return ; @@ -393,15 +436,30 @@ function SendButtonContent({ return {submitLabel}; } if (submitIcon === "return") { - return ; + return ( + + ); } - return ; + return ( + + ); } interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; + sendShortcut: SendShortcut; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; isSubmitLoading: boolean; disabled: boolean; @@ -428,12 +486,21 @@ function handleDesktopKeyPressImpl( } if (event.nativeEvent.key !== "Enter") return; - if (!ctx.submitOnEnter) return; - if (shiftKey) 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 (metaKey || ctrlKey) { + if (action === "alternate-send") { ctx.handleAlternateSendAction(); return; } @@ -776,7 +843,7 @@ function SendButtonTooltip({ submitIcon, submitLabel, submitButtonTestID, - buttonIconSize, + glyph, sendKeys, sendTooltipLabel, }: { @@ -793,7 +860,7 @@ function SendButtonTooltip({ submitIcon: "arrow" | "return"; submitLabel: string | undefined; submitButtonTestID: string | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; sendKeys: ShortcutChord | null | undefined; sendTooltipLabel: string; }) { @@ -810,12 +877,14 @@ function SendButtonTooltip({ testID={submitButtonTestID} style={sendButtonCombinedStyle} > - + + + @@ -1106,12 +1175,13 @@ interface ResolvedMessageInputProps { disabled: boolean; leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; - rightContent: React.ReactNode; + voiceModeContent: React.ReactNode; activeActionContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; isAgentRunning: boolean; defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; @@ -1153,12 +1223,13 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu disabled: props.disabled ?? false, leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, - rightContent: props.rightContent, + voiceModeContent: props.voiceModeContent, activeActionContent: props.activeActionContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior, + sendShortcut: props.sendShortcut, onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, @@ -1180,8 +1251,12 @@ function extractErrorMessage(error: unknown): string | null { return null; } +/** + * Send that stands in for the trailing disc, in fullscreen or while there is nothing to send, is + * disabled until there is content. + */ function resolvePresentationSendDisabled(input: { - fullscreen: boolean; + standsIn: boolean; disabled: boolean; hasText: boolean; attachments: ComposerAttachment[]; @@ -1189,7 +1264,7 @@ function resolvePresentationSendDisabled(input: { allowEmpty: boolean; }): boolean { return ( - input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + input.disabled || (input.standsIn && !input.allowEmpty && !hasSendableComposerContent(input)) ); } @@ -1230,12 +1305,13 @@ export const MessageInput = forwardRef( disabled, leftContent, beforeVoiceContent, - rightContent, + voiceModeContent, activeActionContent, voiceServerId, voiceAgentId, isAgentRunning, defaultSendBehavior, + sendShortcut, onQueue, onSubmitLoadingPress, onKeyPressCallback, @@ -1258,13 +1334,16 @@ export const MessageInput = forwardRef( const isFullscreen = useComposerFullscreen(editingSession); const [renderedLines, setRenderedLines] = useState(1); const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); - const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; + const controlSizing = controlSizingForLayout(isCompact); + const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon; + const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing]; const toast = useToast(); const voice = useVoiceOptional(); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const focusInputKeys = useShortcutKeys("focus-message-input"); const [isInputFocused, setIsInputFocused] = useState(false); + useKeyboardFocusSurface("message-input", isInputFocused && isPaneFocused); // Web text is DOM-owned between deferred draft publications. The action button only needs // this boundary, so publish empty/non-empty transitions without rerendering for every key. const initialHasLiveText = value.trim().length > 0; @@ -1384,6 +1463,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(); @@ -1755,6 +1844,9 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, + sendShortcut, + isAgentRunning, + onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1766,6 +1858,9 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, + sendShortcut, + isAgentRunning, + onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1864,20 +1959,21 @@ export const MessageInput = forwardRef( ); const attachButtonStyle = useCallback( - ({ hovered }: { hovered?: boolean }) => [ + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.attachButton, - Boolean(hovered) && styles.iconButtonHovered, + Boolean(hovered || pressed) && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ], [isConnected, disabled], ); const voiceButtonStyle = useCallback( - ({ hovered }: { hovered?: boolean }) => [ + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.voiceButton, - Boolean(hovered) && !isDictating && styles.iconButtonHovered, - !isDictationStartEnabled && styles.buttonDisabled, isDictating && styles.voiceButtonRecording, + Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered, + Boolean(pressed) && isDictating && styles.discPressed, + !isDictationStartEnabled && styles.buttonDisabled, ], [isDictating, isDictationStartEnabled], ); @@ -1889,7 +1985,7 @@ export const MessageInput = forwardRef( const inputWrapperCombinedStyle = useMemo( () => [ styles.inputWrapper, - isCompact && !isFullscreen && styles.inputWrapperCompact, + Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact, readOnly && styles.inputWrapperReadOnly, inputWrapperStyle, { opacity: surfacePresentation.input.opacity }, @@ -1917,21 +2013,25 @@ export const MessageInput = forwardRef( [mode.isMonospace], ); // Fullscreen always shows Send; without content it is disabled, and looks it. + // With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row + // does not shift when typing starts. + const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null); + const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind; const isPresentedSendDisabled = resolvePresentationSendDisabled({ - fullscreen: isFullscreen, + standsIn: sendStandsIn, disabled: isSendButtonDisabled, hasText: hasLiveText, attachments, hasExternalContent, allowEmpty: allowEmptySubmit, }); - const sendButtonCombinedStyle = useMemo( - () => [ + const sendButtonCombinedStyle = useCallback( + ({ pressed }: { pressed?: boolean }) => [ styles.sendButton, - submitLabel ? styles.sendButtonLabeled : undefined, + Boolean(pressed) && styles.discPressed, isPresentedSendDisabled && styles.buttonDisabled, ], - [isPresentedSendDisabled, submitLabel], + [isPresentedSendDisabled], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -1943,10 +2043,10 @@ export const MessageInput = forwardRef( ), - [onAttachButtonRef, buttonIconSize], + [onAttachButtonRef, toolbarGlyph], ); const renderVoiceButtonIcon = useCallback( @@ -1955,16 +2055,17 @@ export const MessageInput = forwardRef( hovered={Boolean(hovered)} isDictating={isDictating} isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)} - buttonIconSize={buttonIconSize} + glyph={toolbarGlyph} + discGlyphSize={discGlyphSize} /> ), - [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], + [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, toolbarGlyph, discGlyphSize], ); const sendButton = useMemo( () => ( ( submitIcon={submitIcon} submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} - buttonIconSize={buttonIconSize} - sendKeys={DEFAULT_SEND_KEYS} + glyph={toolbarGlyph} + sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), [ - isFullscreen, - primaryActionKind, + presentedActionKind, activeActionContent, canPressLoadingButton, onSubmitLoadingPress, @@ -2000,7 +2100,8 @@ export const MessageInput = forwardRef( submitIcon, submitLabel, submitButtonTestID, - buttonIconSize, + toolbarGlyph, + sendShortcut, sendTooltipLabel, ], ); @@ -2073,9 +2174,25 @@ export const MessageInput = forwardRef( focusInputKeys, ], ); + const leadingEdge = resolveComposerLeadingEdge({ + showsAttach: mode.showAttachments, + showsAgentControls: leftContent != null, + }); + // The trailing control is always a disc: voice mode, then Send or Stop. + const trailingEdge = "disc"; + const buttonRowStyle = useMemo( + () => [ + styles.buttonRow, + { + marginLeft: -composerToolbarOverhang(leadingEdge, controlSizing), + marginRight: -composerToolbarOverhang(trailingEdge, controlSizing), + }, + ], + [controlSizing, leadingEdge, trailingEdge], + ); const toolbar = useMemo( () => ( - + {/* Toolbar left: attachment button + agent controls */} ( voiceMuteToggleKeys={voiceMuteToggleKeys} dictationToggleKeys={dictationToggleKeys} /> - {rightContent} + {voiceModeContent} {sendButton} ), [ + buttonRowStyle, mode.showAttachments, isConnected, disabled, @@ -2130,7 +2248,7 @@ export const MessageInput = forwardRef( isRealtimeVoiceForCurrentAgent, voiceMuteToggleKeys, dictationToggleKeys, - rightContent, + voiceModeContent, sendButton, ], ); @@ -2182,7 +2300,11 @@ export const MessageInput = forwardRef( return ( @@ -2193,7 +2315,8 @@ export const MessageInput = forwardRef( /> ({ position: "relative", }, inputWrapper: { + ...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}), flexShrink: 1, flexDirection: "column", gap: theme.spacing[3], backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius["2xl"], - paddingTop: { - xs: theme.spacing[2], - md: theme.spacing[4], - }, - // The button row bleeds 6px horizontally, so match its corner inset at the bottom. - paddingBottom: { - xs: theme.spacing[2], - md: theme.spacing[3], - }, - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, + borderRadius: COMPOSER_CORNER_RADIUS, + paddingTop: COMPOSER_PADDING_TOP, + paddingBottom: COMPOSER_PADDING_BOTTOM, + paddingHorizontal: COMPOSER_PADDING_HORIZONTAL, ...(isWeb ? { transitionProperty: "border-color", @@ -2320,64 +2434,80 @@ const styles = StyleSheet.create((theme: Theme) => ({ minHeight: MIN_INPUT_HEIGHT, color: theme.colors.foregroundMuted, }, + // The row keeps a full touch target's height; its disc-sized controls centre in it. buttonRow: { + minHeight: TOOLBAR_CONTROL_HEIGHT, flexShrink: 0, flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", justifyContent: "space-between", - marginHorizontal: -6, }, leftButtonGroup: { minWidth: 0, flexShrink: 1, flexGrow: 1, flexDirection: "row", - alignItems: "flex-end", - gap: theme.spacing[0], + alignItems: "center", + gap: TOOLBAR_CONTROL_GAP, }, rightButtonGroup: { flexShrink: 0, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, }, attachButton: { - width: 28, - height: 28, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, attachButtonAnchor: { - width: 28, - height: 28, + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, alignItems: "center", justifyContent: "center", }, voiceButton: { - width: 28, - height: 28, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, voiceButtonRecording: { + marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, + }, + voiceButtonRecordingDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.destructive, + alignItems: "center", + justifyContent: "center", }, sendButton: { - width: 28, - height: 28, + minWidth: TOOLBAR_DISC_SIZE, + marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, + height: TOOLBAR_DISC_SIZE, + alignItems: "center", + justifyContent: "center", + }, + sendButtonDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, - sendButtonLabeled: { - width: "auto", - minWidth: 28, + sendButtonDiscLabeled: { + minWidth: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", }, sendButtonLabel: { fontSize: theme.fontSize.base, @@ -2387,6 +2517,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ iconButtonHovered: { backgroundColor: theme.colors.surface2, }, + discPressed: { + opacity: 0.7, + }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx index 64697999f5..956dc22597 100644 --- a/packages/app/src/composer/input/presentation.tsx +++ b/packages/app/src/composer/input/presentation.tsx @@ -6,14 +6,16 @@ import { Button } from "@/components/ui/button"; import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { isWeb } from "@/constants/platform"; -import { ICON_SIZE } from "@/styles/theme"; +import { + iconSizeFor, + TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_CONTROL_SIZES, + type ControlSizing, +} from "@/components/ui/control-geometry"; import { StyleSheet } from "react-native-unistyles"; import { useComposerFullscreenExit } from "./fullscreen-exit"; import { resolveGlyphRailOffset } from "./state"; -const CONTROL_SIZE = 28; -const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md; - function keepEditorFocused(event: import("react-native").PointerEvent) { if (isWeb) event.preventDefault(); } @@ -27,6 +29,7 @@ function keepEditorFocused(event: import("react-native").PointerEvent) { */ export function ComposerInputPresentation({ fullscreen, + controlSizing, sendIconSize, surface, toolbar, @@ -42,6 +45,8 @@ export function ComposerInputPresentation({ overlay, }: { fullscreen: boolean; + /** The sizing the toolbar's stylesheet resolves to; see `resolveControlSizing`. */ + controlSizing: ControlSizing; sendIconSize: number; surface: ReactNode; toolbar: ReactNode; @@ -61,18 +66,19 @@ export function ComposerInputPresentation({ // action (Escape, hardware back) belongs only to the pane the user is in. const { isActiveComposer } = useComposerKeyboardScope(); const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit); + const glyphSize = iconSizeFor("md")[controlSizing]; const railOffset = resolveGlyphRailOffset({ - controlSize: CONTROL_SIZE, + controlSize: TOOLBAR_CONTROL_SIZES[controlSizing], sendIconSize, - glyphIconSize: FULLSCREEN_GLYPH_SIZE, + glyphIconSize: glyphSize, }); const maximizeIcon = useCallback( - (color: string) => , - [], + (color: string) => , + [glyphSize], ); const minimizeIcon = useCallback( - (color: string) => , - [], + (color: string) => , + [glyphSize], ); // The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge. const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]); @@ -87,7 +93,7 @@ export function ComposerInputPresentation({ - diff --git a/packages/app/src/desktop/browser/settings/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx index ad4140d683..6424750d86 100644 --- a/packages/app/src/desktop/browser/settings/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -66,7 +66,6 @@ export function BrowserDataSection() { )} diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 74cc71dc4a..b2c261543b 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -41,7 +41,6 @@ export function DesktopPermissionsSection() { () => ( - + @@ -284,13 +281,12 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { {daemonLogs?.logPath ? ( - ) : null} - ), - [advancedSettingsIcon, handleOpenAdvancedSettings, t], + const advancedSettingsLink = useMemo( + () => ({ + href: ADVANCED_DAEMON_SETTINGS_URL, + label: t("desktop.daemon.advancedSettings"), + accessibilityLabel: t("desktop.daemon.openAdvancedSettings"), + }), + [t], ); if (!showSection) { @@ -432,7 +410,7 @@ export function LocalDaemonSection() { return ( {isLoading || isLoadingSettings ? ( @@ -471,7 +449,7 @@ export function LocalDaemonSection() { {daemonStatus.home} - - ), - [docsIcon, handleOpenDocs, t], + const docsLink = useMemo( + () => ({ + href: CLI_DOCS_URL, + label: t("settings.integrations.docs.cli"), + accessibilityLabel: t("settings.integrations.docs.openCli"), + }), + [t], ); - if (!showSection) return null; + if (!available) return null; return ( - - - - - - - - {t("settings.integrations.commandLine.title")} - - - - {t("settings.integrations.commandLine.description")} - - - {status?.installed ? ( - - - {t("settings.integrations.actions.installed")} - - ) : ( - - )} - - + + + + ); } -const styles = StyleSheet.create((theme) => ({ - rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, - installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 }, - mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base }, -})); +/** The `paseo` CLI row: on means installed on this computer. */ +export function CommandLineRow({ + value, + onValueChange, + disabled, +}: { + value: boolean; + onValueChange: (next: boolean) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 982469008a..bb5c853aca 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -22,6 +22,8 @@ import { } from "@/components/ui/text-input"; const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; +export const CONNECTIVITY_DOCS_URL = "https://paseo.sh/docs/connectivity"; +export const TAILSCALE_DOCS_URL = `${CONNECTIVITY_DOCS_URL}#tailscale`; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedShieldCheck = withUnistyles(ShieldCheck); @@ -169,7 +171,7 @@ function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => const { t } = useTranslation(); return ( - @@ -219,7 +221,14 @@ function RelayConsent(props: PairDeviceBodyProps) { - {t("pairing.device.directConnectionHint")} + + {t("pairing.device.directConnectionHint")} + + ); @@ -254,12 +263,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) { accessibilityLabel={t("pairing.link.label")} /> - @@ -338,8 +342,12 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, marginTop: 1, // optical: seats the glyph on the hint's first text line }, - directHint: { + directContent: { flex: 1, + alignItems: "flex-start", + gap: theme.spacing[2], + }, + directHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.5, diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 87cc058a0c..ac1427363e 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -326,6 +326,10 @@ export async function installCli(): Promise { return parseInstallStatus(await invokeDesktopCommand("install_cli")); } +export async function uninstallCli(): Promise { + return parseInstallStatus(await invokeDesktopCommand("uninstall_cli")); +} + // COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. export function readLegacySkillSelection(): Promise { return invokeDesktopCommand("read_legacy_skill_selection") as Promise; diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts index 98bc3da804..5a688da449 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -1,10 +1,12 @@ import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { useToast } from "@/contexts/toast-context"; import { getCliInstallStatus, installCli, shouldUseDesktopDaemon, + uninstallCli, type InstallStatus, } from "@/desktop/daemon/desktop-daemon"; import { @@ -14,60 +16,85 @@ import { const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; -interface DesktopInstallHookResult { +interface CliInstall { + /** False outside the desktop app: the CLI installs on the machine running the app. */ + available: boolean; status: InstallStatus | null; isLoading: boolean; - isInstalling: boolean; - error: Error | null; - install: () => void; + isWorking: boolean; + /** + * Installs or removes the CLI; resolves to whether it worked. Failures are reported. Removing + * keeps a paseo command this app did not install, says so, and resolves true. + */ + setInstalled: (installed: boolean) => Promise; refresh: () => void; } -export function useCliInstall(): DesktopInstallHookResult { +export function useCliInstall(): CliInstall { const { t } = useTranslation(); const queryClient = useQueryClient(); const reportError = useDesktopIpcErrorReporter(); - const enabled = shouldUseDesktopDaemon(); + const toast = useToast(); + const available = shouldUseDesktopDaemon(); const statusQuery = useQuery({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY, queryFn: getCliInstallStatus, - enabled, + enabled: available, retry: false, }); - const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery; + const { data: installStatus, isLoading, refetch } = statusQuery; useDesktopIpcQueryErrorToast({ error: statusQuery.error, message: t("desktop.integrations.cli.statusFailed"), logLabel: "[Integrations] Failed to load CLI status", }); - const installMutation = useMutation({ - mutationFn: installCli, - onError: (error) => { + const mutation = useMutation({ + mutationFn: (installed) => (installed ? installCli() : uninstallCli()), + onError: (error, installed) => { reportError({ error, - message: t("desktop.integrations.cli.installFailed"), - logLabel: "[Integrations] Failed to install CLI", + message: installed + ? t("desktop.integrations.cli.installFailed") + : t("desktop.integrations.cli.uninstallFailed"), + logLabel: installed + ? "[Integrations] Failed to install CLI" + : "[Integrations] Failed to uninstall CLI", }); }, onSuccess: (nextStatus) => { queryClient.setQueryData(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus); - void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY }); }, }); - const { error: installError, isPending: isInstalling, mutate: install } = installMutation; + const { mutateAsync, isPending } = mutation; + // Removing only ever deletes the CLI this app installed. A paseo command put there some other way + // stays, and that still counts as done: the user is told rather than left with a switch that + // silently won't turn off. + const setInstalled = useCallback( + (installed: boolean) => + mutateAsync(installed).then( + (status) => { + if (status.installed === installed) return true; + if (installed) return false; + toast.show(t("desktop.integrations.cli.keptForeign")); + return true; + }, + () => false, + ), + [mutateAsync, t, toast], + ); const refresh = useCallback(() => { void refetch(); }, [refetch]); return { + available, status: installStatus ?? null, isLoading, - isInstalling, - error: statusError ?? installError ?? null, - install, + isWorking: isPending, + setInstalled, refresh, }; } diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 080da9c37a..2541da9f79 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -1,6 +1,6 @@ import { Platform } from "react-native"; import { getElectronHost } from "@/desktop/electron/host"; -import type { BrowserKeyboardPolicy } from "@/desktop/browser/shortcuts"; +import type { BrowserKeyboardPolicy } from "@/keyboard"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; type BrowserAutomationExecuteRequest = Extract< diff --git a/packages/app/src/file-explorer/directory-listings.test.ts b/packages/app/src/file-explorer/directory-listings.test.ts new file mode 100644 index 0000000000..8e56b39b2b --- /dev/null +++ b/packages/app/src/file-explorer/directory-listings.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store"; +import { + refreshExplorerDirectories, + restoreExpandedDirectories, + showHiddenFilesAndRestoreExpandedDirectories, + reconcileRestoredExpandedPaths, +} from "./tree"; +import { + DIRECTORY_LISTING_INVALIDATED, + invalidateExplorerDirectoryListings, + loadExplorerDirectory, +} from "./directory-listings"; + +function directory(path: string, names: string[]): ExplorerDirectory { + return { + path, + entries: names.map((name) => ({ + path: `${path}/${name}`, + name, + kind: "file", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + })), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function explorer() { + let state: AgentFileExplorerState = { + listingGeneration: 0, + directories: new Map([[".", directory(".", ["docs"])]]), + isLoading: false, + lastError: null, + pendingRequest: null, + currentPath: ".", + history: ["."], + lastVisitedPath: ".", + selectedEntryPath: null, + }; + const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => { + state = update(state); + return state; + }; + return { + state: () => state, + load: (path: string, result: Promise) => + loadExplorerDirectory({ + path, + updateState, + listDirectory: () => result, + fallbackError: "Directory listing failed", + options: { recordHistory: false, setCurrentPath: false }, + }), + refresh: (retained: readonly string[]) => + invalidateExplorerDirectoryListings(updateState, retained), + }; +} + +describe("directory listings across Refresh", () => { + it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => { + const model = explorer(); + const old = deferred(); + const oldLoad = model.load("docs", old.promise); + model.refresh(["."]); + const fresh = deferred(); + const freshLoad = model.load(".", fresh.promise); + old.resolve(directory("docs", ["OLD.md"])); + + await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED); + expect(model.state().directories.has("docs")).toBe(false); + expect(model.state().isLoading).toBe(true); + expect(model.state().pendingRequest?.path).toBe("."); + + const root = directory(".", ["docs", "NEW.md"]); + fresh.resolve(root); + await expect(freshLoad).resolves.toEqual(root); + expect(model.state().directories.get(".")).toEqual(root); + expect(model.state().isLoading).toBe(false); + }); + + it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => { + const model = explorer(); + const old = deferred(); + const oldLoad = model.load("docs", old.promise); + model.refresh([".", "docs"]); + const fresh = directory("docs", ["NEW.md"]); + await model.load("docs", Promise.resolve(fresh)); + old.resolve(directory("docs", ["OLD.md"])); + + await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED); + expect(model.state().directories.get("docs")).toEqual(fresh); + }); + + it("ignores errors from requests invalidated by Refresh", async () => { + const model = explorer(); + const old = deferred(); + const oldLoad = model.load("docs", old.promise); + model.refresh(["."]); + const fresh = deferred(); + const freshLoad = model.load(".", fresh.promise); + old.reject(new Error("Old request failed")); + + await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED); + expect(model.state().lastError).toBeNull(); + expect(model.state().isLoading).toBe(true); + fresh.resolve(directory(".", ["docs"])); + await freshLoad; + }); + + it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => { + const model = explorer(); + const docs = directory("docs", ["guide.md"]); + await model.load("docs", Promise.resolve(docs)); + await model.load("assets", Promise.resolve(directory("assets", ["logo.png"]))); + model.refresh([".", "docs"]); + + expect(model.state().directories.get("docs")).toEqual(docs); + expect(model.state().directories.has("assets")).toBe(false); + expect(model.state().currentPath).toBe("."); + expect(model.state().history).toEqual(["."]); + }); +}); + +it.each([false, true])( + "preserves saved expansion when Refresh invalidates restoration (hidden: %s)", + async (hidden) => { + const model = explorer(); + const folder = hidden ? ".hidden" : "docs"; + const root: ExplorerDirectory = { + path: ".", + entries: [ + { + name: folder, + path: folder, + kind: "directory", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; + const saved = new Set([".", folder]); + const pending = deferred(); + const input = { + rootDirectory: root, + persistedExpandedPaths: saved, + requestDirectoryListing: (path: string) => model.load(path, pending.promise), + }; + const restoration = hidden + ? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} }) + : restoreExpandedDirectories({ ...input, showHiddenFiles: false }); + const fresh = directory(folder, ["NEW.md"]); + await refreshExplorerDirectories({ + hasWorkspaceScope: true, + expandedPaths: saved, + showHiddenFiles: hidden, + invalidateDirectoryListings: model.refresh, + requestDirectoryListing: (path) => + model.load(path, Promise.resolve(path === "." ? root : fresh)), + }); + pending.resolve(directory(folder, ["OLD.md"])); + const restored = await restoration; + const expanded = reconcileRestoredExpandedPaths({ + persistedExpandedPaths: saved, + currentExpandedPaths: saved, + restoredExpandedPaths: restored, + }); + expect(model.state().directories.get(folder)).toEqual(fresh); + expect(expanded).toEqual([".", folder]); + }, +); diff --git a/packages/app/src/file-explorer/directory-listings.ts b/packages/app/src/file-explorer/directory-listings.ts new file mode 100644 index 0000000000..4d52aaf782 --- /dev/null +++ b/packages/app/src/file-explorer/directory-listings.ts @@ -0,0 +1,91 @@ +import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store"; + +export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated"); +export type DirectoryListingResult = + | ExplorerDirectory + | null + | typeof DIRECTORY_LISTING_INVALIDATED; + +type UpdateExplorerState = ( + update: (state: AgentFileExplorerState) => AgentFileExplorerState, +) => AgentFileExplorerState | null; + +function pushHistory(history: string[], path: string): string[] { + const normalizedHistory = history.length === 0 ? ["."] : history; + return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path]; +} + +export async function loadExplorerDirectory({ + path, + options, + updateState, + listDirectory, + fallbackError, +}: { + path: string; + options?: { recordHistory?: boolean; setCurrentPath?: boolean }; + updateState: UpdateExplorerState; + listDirectory: () => Promise; + fallbackError: string; +}): Promise { + const shouldSetCurrentPath = options?.setCurrentPath ?? true; + const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath; + const initial = updateState((state) => ({ + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path, mode: "list" }, + ...(shouldSetCurrentPath + ? { + currentPath: path, + history: shouldRecordHistory ? pushHistory(state.history, path) : state.history, + lastVisitedPath: path, + } + : {}), + })); + if (!initial) return null; + const generation = initial.listingGeneration; + try { + const directory = await listDirectory(); + let accepted = false; + updateState((state) => { + if (state.listingGeneration !== generation) return state; + accepted = true; + const directories = new Map(state.directories); + directories.set(directory.path, directory); + return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null }; + }); + return accepted ? directory : DIRECTORY_LISTING_INVALIDATED; + } catch (error) { + let invalidated = false; + updateState((state) => { + if (state.listingGeneration !== generation) { + invalidated = true; + return state; + } + return { + ...state, + isLoading: false, + lastError: error instanceof Error ? error.message : fallbackError, + pendingRequest: null, + }; + }); + return invalidated ? DIRECTORY_LISTING_INVALIDATED : null; + } +} + +/** Refresh invalidates earlier replies as well as listings of collapsed folders. */ +export function invalidateExplorerDirectoryListings( + updateState: UpdateExplorerState, + retainedPaths: readonly string[], +): void { + const retained = new Set(retainedPaths); + updateState((state) => ({ + ...state, + listingGeneration: state.listingGeneration + 1, + directories: new Map([...state.directories].filter(([path]) => retained.has(path))), + isLoading: false, + pendingRequest: null, + lastError: null, + })); +} diff --git a/packages/app/src/file-explorer/tree.test.ts b/packages/app/src/file-explorer/tree.test.ts index e3df026c0d..82d4bcc61a 100644 --- a/packages/app/src/file-explorer/tree.test.ts +++ b/packages/app/src/file-explorer/tree.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { ExplorerEntry } from "@/stores/session-store"; +import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; import { MAX_AUTO_EXPANDED_DIRECTORY_DEPTH, flattenExplorerTree, reconcileRestoredExpandedPaths, + refreshExplorerDirectories, restoreExpandedDirectories, setExpandedDirectoryPath, showHiddenFilesAndRestoreExpandedDirectories, @@ -195,3 +196,76 @@ describe("file explorer tree", () => { await expect(restoration).resolves.toEqual([".", ".hidden"]); }); }); + +describe("refreshExplorerDirectories", () => { + function makeListing(path: string, names: string[]): ExplorerDirectory { + return { + path, + entries: names.map((name) => ({ + name, + path: path === "." ? name : `${path}/${name}`, + kind: "file" as const, + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + })), + }; + } + + function createExplorer(disk: Record, cached: Record) { + const directories = new Map( + Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]), + ); + return { + directories, + requestDirectoryListing: async (path: string) => { + const listing = makeListing(path, disk[path] ?? []); + directories.set(path, listing); + return listing; + }, + invalidateDirectoryListings: (retainedPaths: readonly string[]) => { + const retained = new Set(retainedPaths); + for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path); + }, + }; + } + + it("does not keep a stale listing for a collapsed folder", async () => { + // docs/ was expanded and collapsed earlier, then NEW.md was created in it. + const explorer = createExplorer( + { ".": ["docs"], docs: ["README.md", "NEW.md"] }, + { ".": ["docs"], docs: ["README.md"] }, + ); + + await refreshExplorerDirectories({ + hasWorkspaceScope: true, + expandedPaths: new Set(["."]), + showHiddenFiles: false, + requestDirectoryListing: explorer.requestDirectoryListing, + invalidateDirectoryListings: explorer.invalidateDirectoryListings, + }); + + // Expanding docs/ only fetches when it has no cached listing. + expect(explorer.directories.has("docs")).toBe(false); + expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]); + }); + + it("refreshes expanded folders and keeps their listings", async () => { + const explorer = createExplorer( + { ".": ["src"], src: ["a.ts", "b.ts"] }, + { ".": ["src"], src: ["a.ts"] }, + ); + + await refreshExplorerDirectories({ + hasWorkspaceScope: true, + expandedPaths: new Set([".", "src"]), + showHiddenFiles: false, + requestDirectoryListing: explorer.requestDirectoryListing, + invalidateDirectoryListings: explorer.invalidateDirectoryListings, + }); + + expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([ + "a.ts", + "b.ts", + ]); + }); +}); diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts index 8b75ae7b25..4402cd82bc 100644 --- a/packages/app/src/file-explorer/tree.ts +++ b/packages/app/src/file-explorer/tree.ts @@ -1,5 +1,6 @@ import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; import type { SortOption } from "@/stores/panel-store/state"; +import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings"; import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility"; export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; @@ -16,11 +17,13 @@ interface FlattenExplorerTreeInput { showHiddenFiles: boolean; } +type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED; + interface RestoreExpandedDirectoriesInput { rootDirectory: ExplorerDirectory; persistedExpandedPaths: ReadonlySet; showHiddenFiles: boolean; - requestDirectoryListing: (path: string) => Promise; + requestDirectoryListing: (path: string) => Promise; } interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< @@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< interface ReconcileRestoredExpandedPathsInput { persistedExpandedPaths: ReadonlySet; currentExpandedPaths: ReadonlySet; - restoredExpandedPaths: string[]; + restoredExpandedPaths: RestoredExpandedPaths; } interface SetExpandedDirectoryPathInput { @@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({ persistedExpandedPaths, showHiddenFiles, requestDirectoryListing, -}: RestoreExpandedDirectoriesInput): Promise { +}: RestoreExpandedDirectoriesInput): Promise { const restoredPaths = ["."]; const restoredPathSet = new Set(restoredPaths); let parentDirectories = [rootDirectory]; @@ -110,9 +113,11 @@ export async function restoreExpandedDirectories({ const requestedDirectories = await Promise.all( pathsToRequest.map((path) => requestDirectoryListing(path)), ); + if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED)) + return DIRECTORY_LISTING_INVALIDATED; parentDirectories = []; for (const directory of requestedDirectories) { - if (!directory) { + if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) { continue; } restoredPaths.push(directory.path); @@ -128,7 +133,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({ persistedExpandedPaths, showHiddenFiles, requestDirectoryListing, -}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { +}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { showHiddenFiles(); return restoreExpandedDirectories({ rootDirectory, @@ -143,6 +148,8 @@ export function reconcileRestoredExpandedPaths({ currentExpandedPaths, restoredExpandedPaths, }: ReconcileRestoredExpandedPathsInput): string[] { + if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED) + return Array.from(currentExpandedPaths); const reconciledPaths = new Set(restoredExpandedPaths); for (const path of persistedExpandedPaths) { @@ -205,7 +212,7 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): /** Load and expand the path to a directory selected outside the tree. */ export async function revealExplorerDirectory(input: { directoryPath: string; - requestDirectoryListing: (path: string) => Promise; + requestDirectoryListing: (path: string) => Promise; expandPaths: (paths: string[]) => void; showHiddenFiles: () => void; }): Promise { @@ -218,7 +225,51 @@ export async function revealExplorerDirectory(input: { } } for (const path of paths) { - if (!(await input.requestDirectoryListing(path))) return; + const directory = await input.requestDirectoryListing(path); + if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return; } input.expandPaths(paths); } + +/** + * Files Refresh: re-lists the expanded directories (and the root), and drops the cached + * listing of every other directory. Expanding a directory only fetches when it has no + * cached listing, so keeping a collapsed directory's old listing would show it as it was + * before the refresh. + */ +export async function refreshExplorerDirectories({ + hasWorkspaceScope, + expandedPaths, + showHiddenFiles, + requestDirectoryListing, + invalidateDirectoryListings, +}: { + hasWorkspaceScope: boolean; + expandedPaths: Set; + showHiddenFiles: boolean; + requestDirectoryListing: ( + path: string, + opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, + ) => Promise; + invalidateDirectoryListings: (retainedPaths: readonly string[]) => void; +}): Promise { + if (!hasWorkspaceScope) { + return null; + } + const directoryPaths = Array.from(expandedPaths).filter( + (path) => showHiddenFiles || !isHiddenExplorerPath(path), + ); + if (!directoryPaths.includes(".")) { + directoryPaths.unshift("."); + } + invalidateDirectoryListings(directoryPaths); + await Promise.all( + directoryPaths.map((path) => + requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }), + ), + ); + return null; +} diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 5f70030819..3674ed7a50 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -90,7 +90,7 @@ export function FilePane({ {snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")} - diff --git a/packages/app/src/file-panels/html-preview.web.tsx b/packages/app/src/file-panels/html-preview.web.tsx index 471362d8ca..eac275bfe8 100644 --- a/packages/app/src/file-panels/html-preview.web.tsx +++ b/packages/app/src/file-panels/html-preview.web.tsx @@ -25,12 +25,17 @@ const iframeStyle = { export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) { const { t } = useTranslation(); - const document = useMemo(() => withPreviewCsp(html), [html]); + // A document URL lets the browser resolve fragment links within the preview. + // srcDoc would inherit the app URL and navigate the frame back into Paseo. + const source = useMemo( + () => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`, + [html], + ); return (