diff --git a/.gitignore b/.gitignore
index 53a2169c67..6d6bb279b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -93,3 +93,6 @@ app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+
+# Project-local Serena configuration and memories.
+.serena/
diff --git a/.oxlintrc.json b/.oxlintrc.json
index d1566195e9..4582602a97 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -252,7 +252,6 @@
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.tsx",
- "packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78d9717887..cbd1d0551d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,18 +1,57 @@
# Changelog
-## Unreleased
+## 0.11.4 - 2026-10-15
+
+[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
### Fixed
-- Fixed retained terminals not regaining keyboard focus after switching back to their workspace, including when an overlay or another input delays focus until it is released, without taking focus from a newly focused button.
+- Fixed Hoplite showing an error in Settings → Providers instead of discovering models in workspaces linked to a Hoplite project
-## 0.11.0-beta.5 - 2026-10-06
+## 0.11.3 - 2026-10-15
+
+### Added
+
+- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0))
+- Added search to the Add provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030))
+
+### Changed
+
+- Changed Add provider failures to show as a toast ([#7030](https://github.com/getpaseo/paseo/pull/7030))
+
+### Fixed
+
+- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper))
+
+## 0.11.2 - 2026-10-09
+
+### Fixed
+
+- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
+
+## 0.11.1 - 2026-10-07
+
+### Added
+
+- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
+
+### Fixed
+
+- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
+- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
+- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
+
+## 0.11.0 - 2026-10-07
+
+Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
+
+**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
-- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
+- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
@@ -22,19 +61,26 @@
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
+- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
- Added `agents.providers..options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
-- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
+
+### Plugins
+
+- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
+- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
+- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
+- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
### Changed
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
-- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
+- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
@@ -50,12 +96,18 @@
### Fixed
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
-- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
+- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
+- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
+- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
+- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
+- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
+- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
+- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
@@ -70,12 +122,14 @@
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
+- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
+- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
@@ -89,12 +143,18 @@
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
-- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
+- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
+- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
+- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
+- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
+- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
+- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
+- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
@@ -102,12 +162,16 @@
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
+- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
+- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
+- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
+- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
@@ -127,6 +191,7 @@
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
+- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
diff --git a/CLAUDE.md b/CLAUDE.md
index e1ac653338..7076471ff9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
-| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
+| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
diff --git a/README.ja.md b/README.ja.md
index c2d1ad88a0..5d1c8f5ebb 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -53,6 +53,17 @@
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
+## スポンサー
+
+
+
+
## はじめかた
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
@@ -151,11 +162,9 @@ npm run build:server
npm run typecheck
```
-## スポンサー
+## Paseo を支援する
-Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
-
-
+Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
## 関連プロジェクト
diff --git a/README.ko.md b/README.ko.md
index 60581767cd..7f2098221d 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -47,6 +47,17 @@
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
+## 스폰서
+
+
+
+
## 시작하기
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
@@ -161,11 +172,9 @@ npm run build:server
npm run typecheck
```
-## 스폰서
+## Paseo 후원하기
-Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
-
-
+Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
## 관련 프로젝트
diff --git a/README.md b/README.md
index aa0a841e7e..1f6df3c3e4 100644
--- a/README.md
+++ b/README.md
@@ -39,21 +39,44 @@
-Run agents in parallel on your own machines. Ship from your phone or your desk.
+Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
+- **Parallel agents:** Run many agents at once, each in its own worktree.
+- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
+- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
+[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
+
+## Sponsors
+
+
+
+
## Plugins
-Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
-TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install `.
+Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
+iOS, and Android. Write a plugin once and it is on your phone.
-Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
-machine and inside connected clients; install only code you trust.
+- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
+- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
+- **Providers:** add a coding agent as a provider.
+
+Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
+
+**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
+
+Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
## Getting Started
@@ -77,16 +100,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
To connect from your phone, open **Settings → your host → Pair Device**.
-### CLI / headless
+### Server
-Install the CLI and start Paseo:
+For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
```bash
npm install -g @getpaseo/cli
paseo
```
-Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
+Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
For full setup and configuration, see:
@@ -195,11 +218,9 @@ npm run build:server
npm run typecheck
```
-## Sponsors
+## Support Paseo
-Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
-
-
+Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs.
## Related projects
diff --git a/README.zh-CN.md b/README.zh-CN.md
index dfdf673b2e..745071f0fe 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -53,6 +53,17 @@
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
+## 赞助商
+
+
+
+
## 快速开始
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
@@ -151,11 +162,9 @@ npm run build:server
npm run typecheck
```
-## 赞助
+## 支持 Paseo
-Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
-
-
+Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
## 相关项目
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 8662ee3287..378a0070ea 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -69,11 +69,11 @@ 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.
-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 only selects an existing workspace.
+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.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
-`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
+`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
@@ -84,6 +84,32 @@ 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
+
+`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.
+
+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.
+
+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`.
+
+### Private daemon helpers
+
+Branch-name and commit-message generators still use private disposable internal runtimes.
+`AgentManager` does not persist them or their provider sessions, excludes them from public
+discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
+its exact-ID result briefly to cover completion before a waiter attaches. This internal
+lifecycle is separate from public workspace visibility and is not a public creation option.
+
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
@@ -125,11 +151,12 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
-Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
-purpose initializes a temporary app-server, reads the persisted thread and child histories, and
-releases the process before returning. It never loads, resumes, or unarchives a native thread,
-including legacy records whose native archive failed. The retained history session contains only
-the read results. Interactive resume remains responsible for repairing a provider session archived
+Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
+history session contains only the read results and must release its provider process once replay
+finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
+loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
+ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
+before returning. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
@@ -185,7 +212,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. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
+Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
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.
@@ -197,10 +224,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
-- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
+- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
+- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
- **On replay, `/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md
index b31a3fd9ee..e62f155fd5 100644
--- a/docs/agent-stream-performance.md
+++ b/docs/agent-stream-performance.md
@@ -58,12 +58,17 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
through that hook; the web viewport once skipped it and history hosts of a live tool group went
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
silently stops.
+- **Streaming cost must not grow with revealed history.** Reading older messages must not
+ make the next reply progressively more expensive to display. Reuse unchanged history work
+ while text arrives, and release obsolete window caches as the reader reveals more history;
+ window caches must stay bounded while their source timeline remains loaded.
## Measuring
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
+- **Per-delta cost on a long timeline:** `npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts` from `packages/app` replays 200 deltas over a 2,000-item timeline through the pure model and layout pipeline, with the default mounted window and with history fully revealed. It measures the JS work per delta, not paint.
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
diff --git a/docs/android.md b/docs/android.md
index f04ce945bb..75908a8ae2 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -124,7 +124,9 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
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 vertical scale is inverted. Dragging and explicit scroll
+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.
Register this package before React Native's core package through its Expo config plugin. Normal
diff --git a/docs/architecture.md b/docs/architecture.md
index fdf7ec909a..4ed25ba1c4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -345,6 +345,10 @@ join active work or reuse committed stages. A persisted resource alone cannot pr
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
+Agent registration commits creation. Rejecting the first prompt is a turn error on that
+agent; reporting creation failure leaves an orphaned agent and invites another Create.
+Receipt replay must not resend the prompt.
+
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
diff --git a/docs/custom-providers.md b/docs/custom-providers.md
index efc5fdf24f..a6c6cefbdc 100644
--- a/docs/custom-providers.md
+++ b/docs/custom-providers.md
@@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
-Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
+Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead.
### Adding a generic ACP provider
diff --git a/docs/data-model.md b/docs/data-model.md
index 25a214b11c..1b578bb6d8 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -589,7 +589,10 @@ host, kinds, and ids. Opening a cached workspace uses exact workspace and projec
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
-checkpoint are also applied in one transaction.
+checkpoint are also applied in one transaction. Reads wait for accepted changes to reach storage,
+then retry only when a requested row changes during the read. Unrelated agent streams do not
+invalidate the read. Directory baseline replacements and host removal or renaming still invalidate
+the affected reads.
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
diff --git a/docs/development.md b/docs/development.md
index caba38adc9..437b635fec 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
+### Desktop Linux hidden-window fallback
+
+On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
+forever without a single compositor frame: the renderer loads and runs, but
+`ready-to-show` never fires, so the window is never shown and the app looks like
+it did not start while every process is alive. `showWindowWhenReady`
+(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
+the document finishes loading if the event has not arrived. The fallback is
+Linux-only; macOS and Windows keep waiting for `ready-to-show`.
+
+Run the startup regression on Linux with an isolated display:
+
+```bash
+xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
+```
+
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md
index f1e27ac548..c9a89f8a58 100644
--- a/docs/explorer-sidebar.md
+++ b/docs/explorer-sidebar.md
@@ -7,6 +7,7 @@ contracts.
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
+| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
## Panel host contract
@@ -22,6 +23,36 @@ can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
+## Sheets
+
+On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher
+keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
+(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
+sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
+ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
+other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
+subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
+(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
+sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
+
+The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
+remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
+ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
+dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
+
+Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
+handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
+holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
+Close, so the title keeps its width. The sheets present
+from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
+workspace provider an ordinary pane has without a context bridge
+([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
+
+A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
+Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
+mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
+the sheet's tab into a pane, and edits the autosave has not started writing are lost.
+
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
@@ -62,7 +93,9 @@ lifecycle.
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
-right split around the workspace root; later side opens reuse it.
+right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
+mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
+pane, so callers never check whether splits render.
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
diff --git a/docs/floating-panels.md b/docs/floating-panels.md
index c08076b85c..526ce868ca 100644
--- a/docs/floating-panels.md
+++ b/docs/floating-panels.md
@@ -203,6 +203,46 @@ viewport keeps the last keyboard reservation until the next opening replaces
it, so a capped draft keeps one height through every keyboard transition
instead of expanding on close and shrinking again on open.
+Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
+fullscreen` fills the stationary viewport with the composer slot over the
+still-mounted content, paints its own surface under the safe areas, and turns
+the surface translation off. The composer's single tree keeps the editing
+surface at the same position in both modes, so the mounted editor, its native
+buffer, selection, and focus survive the toggle and Send's clear always reaches
+the live input. A viewport crossing the compact breakpoint still remounts it:
+the app shell swaps its gesture wrapper and the workspace screen
+mounts tabs in different trees, so every screen below remounts. The draft owner
+restores the text and the editing session restores the caret. Focus restoration
+is limited to mouse-driven UIs, using the same fine-pointer policy as autofocus.
+New workspace keys its sessions by draft in the app-level store for the same
+reason. The centered form honors fullscreen by style alone: no centering, no
+translation, setup fields hidden, slot filling the stationary viewport. The
+global exit action (Escape, hardware back) is registered only for the active
+composer: a retained tab keeps its fullscreen editor mounted while hidden, the
+sheet suspends pane focus, and the composer gives up input from the moment the
+user asks for the compact agent list or explorer, before the panel's motion
+settles. Pane focus itself stays put under those panels: the agent panel reads
+it as "the user is in this chat" for attention clearing, and opening the agent
+list must not clear the attention it shows. On mouse-driven UIs, focus restoration
+follows pane focus, not the active composer. Keying it on the active composer
+made sidebar dismissal look like a hidden-tab blur and refocused on close.
+Native and touch-web navigation must not replay old editor focus, including
+when blur arrives after pane deactivation or the editor unmounts before blur.
+Fullscreen toggles keep the editor mounted and need no focus restoration.
+The band beside the inline editor where the fullscreen glyph sits is the input's
+own right padding, so a tap anywhere in it focuses the editor whether or not the
+glyph shows. On web the slot is a fixed box over the visible viewport, which is
+also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
+height as a bottom margin at move start and end (boundary-time, like the
+capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
+frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
+and without animating the editor's height. The screen that owns the chrome
+unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
+active tab and New workspace reads the mounted composer's session. Hosts that
+render the dock pass the same `ComposerEditingSession` to the `Composer` so the
+dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
+registration, focus untouched) and the Android back button leave fullscreen.
+
Keep setup fields out of the composer's shrink chain. New workspace supplies its
setup fields as the first dock child so they move up under the header as the draft
grows. Putting the fields and composer in one bounded, shrinkable column made
@@ -307,9 +347,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
-All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
-includes native centered dialogs: separate React Native Modals cannot present a root-owned
-sibling dialog over an existing iOS presenter.
+All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
+exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
+close. This includes native centered dialogs: separate React Native Modals cannot present a
+root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
+(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
+panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
+portal host and container measurement while the sheet still joins the same stack. That export is
+what `patches/@gorhom+bottom-sheet+*.patch` adds.
+
+A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
+close path then parks the sheet at index -1 with its content mounted; the owner hears
+`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the
+sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and
+unmounts it, so its backdrop and Back press call `close()`.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
@@ -319,9 +370,10 @@ forwards commands to the current Gorhom handle.
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
-keeps its press target until portal teardown and asks the provider to dismiss the top modal.
-Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
-retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
+keeps its press target until portal teardown and asks the provider to dismiss the top modal
+(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the
+authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the
+same push/replace presentation semantics.
## Gotcha 7 — A sheet cannot read context from its call site
diff --git a/docs/glossary.md b/docs/glossary.md
index f9eb458173..644c109d44 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -21,6 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
+- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
+- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -33,8 +35,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
-- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
-- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
+- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
+- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
diff --git a/docs/menus.md b/docs/menus.md
index 50434b9e9d..9dc3e26cae 100644
--- a/docs/menus.md
+++ b/docs/menus.md
@@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
+A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
+sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
+closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
+store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
+
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
@@ -193,6 +198,9 @@ its own.
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
+- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its
+ retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the
+ menu would reappear with the panel on the next tab switch.
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
diff --git a/docs/plugins.md b/docs/plugins.md
index 387f24f46b..4e1985ea81 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun
appear in the plugin catalog. Editing one in development requires a daemon restart.
Directory, Git, and npm installs cannot use a built-in ID.
+A provider registration with `requiresConfiguration: true` participates only while its ID exists
+under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
+without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
+`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
+provider does not enter executable resolution or catalog discovery.
+
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
availability and a diagnostic after the daemon resolves the executable. Configure command,
@@ -117,15 +123,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
-Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
-`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
-`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
-
-With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
-artifact, and GitHub shorthand requires `github:`. Registry installs
+`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
+Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
+Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
+Registry overviews are untrusted author content, even before installation. Render them with
+`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
+URLs, and isolated external links. The website adapter and corpus tests live in
+`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
+`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
+this policy. Installed-plugin descriptions in the app remain plain text.
+
Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under
@@ -138,13 +148,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
-GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
-URL. An existing directory still wins over source resolution.
+GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
+URL.
```bash
-paseo plugin install owner/repository
+paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
-paseo plugin install owner/monorepo:plugins/review
+paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -155,8 +165,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
## Managed source ownership
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
-syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
-through `installPluginSource`; only the daemon resolves host paths and acquires sources.
+syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
+calling `installPluginSource`; see the public source reference for CLI and app path semantics.
+Only the daemon reads plugin files and acquires sources.
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
@@ -488,7 +499,7 @@ SVG or URL.
## Usage sources
-Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
+Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
diff --git a/docs/providers.md b/docs/providers.md
index c5f37d4161..298bee651b 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
+Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
+
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers..paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
@@ -103,7 +105,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
-A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
+A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
diff --git a/docs/service-proxy.md b/docs/service-proxy.md
index 3ec3144289..cab7f42774 100644
--- a/docs/service-proxy.md
+++ b/docs/service-proxy.md
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
## How it works
-When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
+When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
The generated hostname is built from the script name, branch, and project:
diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md
index 8264fe2683..d1a770f219 100644
--- a/docs/timeline-sync.md
+++ b/docs/timeline-sync.md
@@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one
message.
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
-prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
+human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
row is recorded before handler output. The app tracks submission transactions only for hosts with this
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
@@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
-Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
-the Paseo timeline, so one visible response can span several canonical turns without a user message
-between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
+Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
+notifications project into tool rows, so they do not create a human-message boundary. Legacy system
+prompts, including schedule injections, remain hidden. One visible response can span several canonical
+turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
+Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
+provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
+also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and
+do not imply local ownership or authorization. Capture the sender title at delivery: archived or
+removed senders must remain understandable without a live directory lookup.
+
+The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
+presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
+must not construct UI labels or prepend sender labels to the semantic message body. The existing
+`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
+expand its body. New clients use the typed payload, never infer the event from a tool name or parse
+XML. Stream reduction and disk-cache serialization preserve the payload without rendering it.
+
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
@@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
liveness, never from submission records or whichever timeline rows happen to be mounted.
-The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
+For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
the wire. A correlated provider echo records the provider's native identity internally without
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
diff --git a/fastlane/metadata/android/en-US/changelogs/110001.txt b/fastlane/metadata/android/en-US/changelogs/110001.txt
new file mode 100644
index 0000000000..1624e3b4a3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110001.txt
@@ -0,0 +1,7 @@
+Added
+- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
+- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
+- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
+- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110002.txt b/fastlane/metadata/android/en-US/changelogs/110002.txt
new file mode 100644
index 0000000000..1624e3b4a3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110002.txt
@@ -0,0 +1,7 @@
+Added
+- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
+- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
+- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
+- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110003.txt b/fastlane/metadata/android/en-US/changelogs/110003.txt
new file mode 100644
index 0000000000..1624e3b4a3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110003.txt
@@ -0,0 +1,7 @@
+Added
+- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
+- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
+- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
+- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110004.txt b/fastlane/metadata/android/en-US/changelogs/110004.txt
new file mode 100644
index 0000000000..1624e3b4a3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110004.txt
@@ -0,0 +1,7 @@
+Added
+- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
+- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
+- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
+- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110011.txt b/fastlane/metadata/android/en-US/changelogs/110011.txt
new file mode 100644
index 0000000000..c5b462f3b5
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110011.txt
@@ -0,0 +1,8 @@
+Added
+- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
+
+Fixed
+- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
+- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110012.txt b/fastlane/metadata/android/en-US/changelogs/110012.txt
new file mode 100644
index 0000000000..c5b462f3b5
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110012.txt
@@ -0,0 +1,8 @@
+Added
+- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
+
+Fixed
+- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
+- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110013.txt b/fastlane/metadata/android/en-US/changelogs/110013.txt
new file mode 100644
index 0000000000..c5b462f3b5
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110013.txt
@@ -0,0 +1,8 @@
+Added
+- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
+
+Fixed
+- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
+- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110014.txt b/fastlane/metadata/android/en-US/changelogs/110014.txt
new file mode 100644
index 0000000000..c5b462f3b5
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110014.txt
@@ -0,0 +1,8 @@
+Added
+- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
+
+Fixed
+- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
+- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110021.txt b/fastlane/metadata/android/en-US/changelogs/110021.txt
new file mode 100644
index 0000000000..92dc176fa3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110021.txt
@@ -0,0 +1,4 @@
+Fixed
+- Fixed intermittent text clipping in iOS chats and Markdown previews
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110022.txt b/fastlane/metadata/android/en-US/changelogs/110022.txt
new file mode 100644
index 0000000000..92dc176fa3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110022.txt
@@ -0,0 +1,4 @@
+Fixed
+- Fixed intermittent text clipping in iOS chats and Markdown previews
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110023.txt b/fastlane/metadata/android/en-US/changelogs/110023.txt
new file mode 100644
index 0000000000..92dc176fa3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110023.txt
@@ -0,0 +1,4 @@
+Fixed
+- Fixed intermittent text clipping in iOS chats and Markdown previews
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/110024.txt b/fastlane/metadata/android/en-US/changelogs/110024.txt
new file mode 100644
index 0000000000..92dc176fa3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/110024.txt
@@ -0,0 +1,4 @@
+Fixed
+- Fixed intermittent text clipping in iOS chats and Markdown previews
+
+Full notes: https://paseo.sh/changelog
diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix
index dae45db386..3fd5f47093 100644
--- a/nix/desktop-package.nix
+++ b/nix/desktop-package.nix
@@ -86,6 +86,12 @@ buildNpmPackage {
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
+ # Metro's worker runs out of heap transforming the generated
+ # ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
+ # 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
+ # macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
+ # only raises the limit on smaller builders, like desktop-release.yml does.
+ NODE_OPTIONS = "--max-old-space-size=4096";
};
buildPhase = ''
diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash
index d41138cbb1..8906abc66b 100644
--- a/nix/npm-deps.hash
+++ b/nix/npm-deps.hash
@@ -1 +1 @@
-sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
+sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
diff --git a/package-lock.json b/package-lock.json
index d03e712ee1..c31951b1b3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36413,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37496,12 +37496,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/client": "0.11.0-beta.5",
- "@getpaseo/protocol": "0.11.0-beta.5",
- "@getpaseo/server": "0.11.0-beta.5",
+ "@getpaseo/client": "0.11.2",
+ "@getpaseo/protocol": "0.11.2",
+ "@getpaseo/server": "0.11.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37748,10 +37748,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
- "@getpaseo/protocol": "0.11.0-beta.5",
- "@getpaseo/relay": "0.11.0-beta.5",
+ "@getpaseo/protocol": "0.11.2",
+ "@getpaseo/relay": "0.11.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38006,7 +38006,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38931,14 +38931,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
- "@getpaseo/client": "0.11.0-beta.5",
- "@getpaseo/protocol": "0.11.0-beta.5",
+ "@getpaseo/client": "0.11.2",
+ "@getpaseo/protocol": "0.11.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38948,8 +38948,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
- "@getpaseo/client": "0.11.0-beta.5",
- "@getpaseo/protocol": "0.11.0-beta.5",
+ "@getpaseo/client": "0.11.2",
+ "@getpaseo/protocol": "0.11.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
- "@getpaseo/client": "0.11.0-beta.5",
- "@getpaseo/highlight": "0.11.0-beta.5",
- "@getpaseo/plugin": "0.11.0-beta.5",
- "@getpaseo/protocol": "0.11.0-beta.5",
- "@getpaseo/relay": "0.11.0-beta.5",
+ "@getpaseo/client": "0.11.2",
+ "@getpaseo/highlight": "0.11.2",
+ "@getpaseo/plugin": "0.11.2",
+ "@getpaseo/protocol": "0.11.2",
+ "@getpaseo/relay": "0.11.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40146,7 +40146,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -40174,6 +40174,7 @@
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1",
+ "parse5": "^7.3.0",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4",
@@ -41753,7 +41754,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
diff --git a/package.json b/package.json
index 3eebf95522..ecbfd83891 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "paseo",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
diff --git a/packages/app/app.config.js b/packages/app/app.config.js
index 79d68249f2..e09a06f71d 100644
--- a/packages/app/app.config.js
+++ b/packages/app/app.config.js
@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
+const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
@@ -145,6 +146,7 @@ export default {
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
+ withAndroidOptionalCamera,
[
"expo-splash-screen",
{
diff --git a/packages/app/app.config.test.ts b/packages/app/app.config.test.ts
new file mode 100644
index 0000000000..b98258ff9a
--- /dev/null
+++ b/packages/app/app.config.test.ts
@@ -0,0 +1,61 @@
+import { createRequire } from "node:module";
+import { afterEach, describe, expect, it } from "vitest";
+
+// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
+const requireFromExpo = createRequire(require.resolve("expo/package.json"));
+const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
+const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
+const { compileModsAsync } = requireFromExpoCli(
+ "@expo/config-plugins/build/plugins/mod-compiler.js",
+);
+
+interface ManifestEntry {
+ $: Record;
+}
+
+const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
+
+async function resolveAndroidManifest() {
+ const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
+ await compileModsAsync(exp, {
+ projectRoot: __dirname,
+ introspect: true,
+ platforms: ["android"],
+ assertMissingModProviders: false,
+ });
+ return exp._internal.modResults.android.manifest.manifest;
+}
+
+describe("Android app config", () => {
+ afterEach(() => {
+ if (originalFdroidFlag === undefined) {
+ delete process.env.PASEO_FDROID_BUILD;
+ } else {
+ process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
+ }
+ });
+
+ it.each([
+ ["Google Play", "0"],
+ ["F-Droid", "1"],
+ ])(
+ "%s build installs on devices without a camera",
+ async (_build, fdroidFlag) => {
+ process.env.PASEO_FDROID_BUILD = fdroidFlag;
+ const manifest = await resolveAndroidManifest();
+ const permissions = (manifest["uses-permission"] ?? []).map(
+ (entry: ManifestEntry) => entry.$["android:name"],
+ );
+ const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
+
+ expect(permissions).toContain("android.permission.CAMERA");
+ expect(features).toEqual(
+ expect.arrayContaining([
+ { "android:name": "android.hardware.camera", "android:required": "false" },
+ { "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
+ ]),
+ );
+ },
+ 30_000,
+ );
+});
diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts
index a0d94e5c02..ceca7489e3 100644
--- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts
+++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts
@@ -1,13 +1,24 @@
-import { test } from "../support/fixtures";
+import {
+ closeModelPicker,
+ openModelPicker,
+ seedAgentProfiles,
+} from "../support/helpers/agent-profiles";
+import { expectComposerVisible } from "../support/helpers/composer";
+import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { writeFile } from "node:fs/promises";
+import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getServerId } from "../support/helpers/server-id";
import {
expectProviderInstalledInSettings,
+ findAcpCatalogProvider,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
+ seedSavedSettingsHosts,
} from "../support/helpers/settings";
const ACP_PROVIDER = {
@@ -16,8 +27,13 @@ const ACP_PROVIDER = {
};
interface ProviderCatalogDaemonClient {
+ getLastServerInfoMessage(): {
+ features?: { hopliteProvider?: boolean; plugins?: boolean };
+ } | null;
connect(): Promise;
close(): Promise;
+ getDaemonConfig(): Promise<{ config: { providers?: Record } }>;
+ getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise;
}
@@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => {
}
});
});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
+ const client = await connectDaemonClient({
+ clientIdPrefix: "hoplite-catalog-e2e",
+ });
+ try {
+ expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true);
+ await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
+ expect(
+ (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
+ ).toBe(false);
+ await page.addInitScript((value) => {
+ localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value }));
+ }, theme);
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHost(page, getServerId());
+ await openSettingsHostSection(page, getServerId(), "providers");
+ await expect(
+ page.getByRole("button", { name: "Hoplite provider details", exact: true }),
+ ).toHaveCount(0);
+ await openAddProviderArea(page);
+ await findAcpCatalogProvider(page, "Hoplite");
+ await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
+ await installAcpCatalogProvider(page, "Hoplite");
+ await expectProviderInstalledInSettings(page, "Hoplite");
+ await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) });
+ expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true });
+ await writeFile(
+ testInfo.outputPath(`hoplite-added-${theme}.json`),
+ JSON.stringify(await client.getProvidersSnapshot(), null, 2),
+ );
+ await removeHoplite(page);
+ await expect(
+ page.getByRole("button", { name: "Hoplite provider details", exact: true }),
+ ).toHaveCount(0);
+ expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
+ expect(
+ (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
+ ).toBe(false);
+ await writeFile(
+ testInfo.outputPath(`hoplite-removed-${theme}.json`),
+ JSON.stringify(await client.getProvidersSnapshot(), null, 2),
+ );
+ await openAddProviderArea(page);
+ await findAcpCatalogProvider(page, "Hoplite");
+ await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
+ await installAcpCatalogProvider(page, "Hoplite");
+ await expectProviderInstalledInSettings(page, "Hoplite");
+ } finally {
+ await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined);
+ await client.close().catch(() => undefined);
+ }
+ });
+}
+
+async function removeHoplite(page: import("@playwright/test").Page): Promise {
+ await page.getByTestId("provider-actions-hoplite").click();
+ page.once("dialog", (dialog) => {
+ void dialog.accept();
+ });
+ await page.getByTestId("provider-remove-hoplite").click();
+}
+
+test("unsupported host asks for an update without writing Hoplite config", async ({
+ page,
+ outdatedDaemon,
+}, testInfo) => {
+ const client = await connectDaemonClient({
+ clientIdPrefix: "hoplite-unsupported",
+ port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port),
+ });
+ try {
+ expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true);
+ expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false);
+ const before = await client.getDaemonConfig();
+ await seedSavedSettingsHosts(page, [outdatedDaemon]);
+ await page.reload();
+ await openSettings(page);
+ await openSettingsHost(page, outdatedDaemon.serverId);
+ await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
+ await openAddProviderArea(page);
+ await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
+ const errorToast = page.getByTestId("app-toast-message");
+ await expect(errorToast).toHaveText("Update the host to add Hoplite.");
+ await expect(errorToast).toBeInViewport();
+ expect((await client.getDaemonConfig()).config).toEqual(before.config);
+ await expect(
+ page.getByRole("button", { name: "Hoplite provider details", exact: true }),
+ ).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") });
+ } finally {
+ await client.close();
+ }
+});
+
+test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => {
+ const client = await connectDaemonClient({
+ clientIdPrefix: "hoplite-picker",
+ });
+ const profiles = await seedAgentProfiles([
+ { id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" },
+ ]);
+ const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" });
+ try {
+ await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
+ await gotoWorkspace(page, workspace.workspaceId);
+ await clickNewChat(page);
+ await expectComposerVisible(page);
+ await openModelPicker(page);
+ await expect(page.getByTestId("model-provider-mock")).toBeVisible();
+ await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") });
+ await closeModelPicker(page);
+ } finally {
+ await workspace.cleanup();
+ await profiles.restore();
+ await client.close();
+ }
+});
diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts
index 027d6156e7..f66b963d5f 100644
--- a/packages/app/e2e/browser/add-project-flow.spec.ts
+++ b/packages/app/e2e/browser/add-project-flow.spec.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
-import { mkdtemp, rm, stat } from "node:fs/promises";
-import { tmpdir } from "node:os";
+import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
+import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
@@ -12,12 +12,15 @@ import {
chooseAddProjectMethod,
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
+ expectAddProjectResultVisible,
+ navigateAddProjectResults,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
addConnectedHostAndReload,
+ addConnectedHostsAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "../support/helpers/hosts";
@@ -214,6 +217,60 @@ test.describe("Add Project command-center flow", () => {
});
});
+ test("filtered hosts keep the keyboard-selected row visible", async ({ page }) => {
+ const hosts: IsolatedHostDaemon[] = [];
+ try {
+ for (let index = 1; index <= 3; index += 1) {
+ hosts.push(await startIsolatedHostDaemon(`scroll-host-${index}`));
+ }
+ await page.setViewportSize({ width: 1280, height: 360 });
+ await gotoAppShell(page);
+ await addConnectedHostsAndReload(
+ page,
+ hosts.map((host, index) => ({
+ serverId: host.serverId,
+ port: host.port,
+ label: index < 2 ? `Fleet 0${index + 1}` : "Other host",
+ })),
+ { primaryLabel: "Fleet 00" },
+ );
+ for (const host of hosts) {
+ await waitForConnectedHost(page, {
+ serverId: host.serverId,
+ endpoint: `localhost:${host.port}`,
+ });
+ }
+ await openAddProjectHostSelection(page);
+ await expect(
+ addProjectFlow(page).getByRole("button").filter({ hasText: "Other host" }),
+ ).toBeVisible();
+
+ await addProjectFlowInput(page).fill("Fleet");
+ await expect(addProjectFlowHost(page, hosts[2]!.serverId)).toHaveCount(0);
+ await navigateAddProjectResults(page, "ArrowDown", 2);
+ await expectAddProjectResultVisible(page, "Fleet 02");
+
+ // Filtering out the first host moves retained rows to new positions.
+ await addProjectFlowInput(page).fill("scroll-host");
+ await expect(addProjectFlowHost(page, getServerId())).toHaveCount(0);
+ await expectAddProjectResultVisible(page, "Fleet 01");
+ await navigateAddProjectResults(page, "ArrowDown", 2);
+ await expectAddProjectResultVisible(page, "Other host");
+ await navigateAddProjectResults(page, "ArrowDown", 1);
+ await expectAddProjectResultVisible(page, "Fleet 01");
+
+ await addProjectFlowInput(page).fill("Fleet");
+ await expectAddProjectResultVisible(page, "Fleet 00");
+ await navigateAddProjectResults(page, "ArrowDown", 2);
+ await expectAddProjectResultVisible(page, "Fleet 02");
+ await page.keyboard.press("Enter");
+ await expectAddProjectPage(page, "method");
+ await expect(addProjectFlow(page)).toContainText("Fleet 02");
+ } finally {
+ await Promise.all(hosts.map((host) => host.close()));
+ }
+ });
+
test("keyboard directory search adds the selected Project", async ({
page,
projectPickerFixture,
@@ -223,6 +280,7 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
+ await expect(addProjectFlowInput(page)).toBeFocused();
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
@@ -240,6 +298,59 @@ test.describe("Add Project command-center flow", () => {
await expectProjectHasNoWorkspaces(projectId);
});
+ test("keyboard navigation keeps the selected row inside the visible results", async ({
+ page,
+ }) => {
+ const parentDirectory = await mkdtemp(path.join(homedir(), "paseo-e2e-scroll-"));
+ const rowPath = (index: number) =>
+ path.join(parentDirectory, `scroll-probe-${String(index).padStart(2, "0")}`);
+ let projectId: string | null = null;
+ try {
+ await Promise.all(Array.from({ length: 16 }, (_, index) => mkdir(rowPath(index))));
+ await gotoAppShell(page);
+ await openAddProjectFlow(page);
+ await chooseAddProjectMethod(page, "directory-search");
+ await addProjectFlowInput(page).fill(path.basename(parentDirectory));
+ const results = page.getByTestId("add-project-flow-results");
+ await expect(results.getByRole("button", { name: /scroll-probe-/ })).toHaveCount(16, {
+ timeout: 30_000,
+ });
+
+ await navigateAddProjectResults(page, "ArrowDown", 12);
+ await expectAddProjectResultVisible(page, rowPath(11));
+ await navigateAddProjectResults(page, "ArrowUp", 12);
+ await expectAddProjectResultVisible(page, parentDirectory);
+ await navigateAddProjectResults(page, "ArrowUp", 1);
+ await expectAddProjectResultVisible(page, rowPath(15));
+ await navigateAddProjectResults(page, "ArrowDown", 1);
+ await expectAddProjectResultVisible(page, parentDirectory);
+
+ await navigateAddProjectResults(page, "ArrowDown", 12);
+ await expectAddProjectResultVisible(page, rowPath(11));
+ await addProjectFlowInput(page).fill(`${path.basename(parentDirectory)}probe`);
+ await expect(results.getByRole("button")).toHaveCount(16);
+ await expectAddProjectResultVisible(page, rowPath(0));
+ await navigateAddProjectResults(page, "ArrowDown", 12);
+ await expectAddProjectResultVisible(page, rowPath(12));
+ await addProjectFlowInput(page).fill(path.basename(parentDirectory));
+ await expect(results.getByRole("button")).toHaveCount(17);
+ await expectAddProjectResultVisible(page, parentDirectory);
+ await navigateAddProjectResults(page, "ArrowDown", 12);
+ await expectAddProjectResultVisible(page, rowPath(11));
+ await page.keyboard.press("Enter");
+ projectId = await expectOpenedProject(page, "scroll-probe-11");
+ await expectNewWorkspaceForAddedProject(page, {
+ serverId: getServerId(),
+ projectId,
+ projectName: "scroll-probe-11",
+ projectPath: rowPath(11),
+ });
+ } finally {
+ if (projectId) await removeCreatedProject(rowPath(11), projectId);
+ await rm(parentDirectory, { recursive: true, force: true });
+ }
+ });
+
test("a complete repository URL remains selectable without a GitHub search result", async ({
page,
}) => {
diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts
index 75cf7ff0c9..3bfda6b4b0 100644
--- a/packages/app/e2e/browser/agent-message-submission.spec.ts
+++ b/packages/app/e2e/browser/agent-message-submission.spec.ts
@@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored(
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
- await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
if (input.preservesActiveTurn) {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
return;
@@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored(
async function retryRestoredSubmission(page: Page, prompt: string): Promise {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
- const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
- await expect(userMessage).toHaveCount(1);
+ const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
+ await expect(attempts).toHaveCount(2);
+ const userMessage = attempts.last();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
+async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
+ const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
+ await expect(messages).toHaveCount(2);
+ await expect(messages.nth(0)).toContainText(prompt);
+ await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
+}
+
async function configureSteerInSettings(page: Page): Promise {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
@@ -1173,13 +1180,14 @@ test.describe("Agent message submission", () => {
await toasts.expectNeverShown("agent-updating-toast");
});
- test("restores a rejected submission and accepts its retry", async ({
+ test("retains a rejected submission before its error and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
+ await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
await retryRestoredSubmission(page, prompt);
});
@@ -1209,6 +1217,7 @@ test.describe("Agent message submission", () => {
errorMessage: "Requested mock steer transport failure",
preservesActiveTurn: true,
});
+ await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
diff --git a/packages/app/e2e/browser/agent-stream-smoothness.spec.ts b/packages/app/e2e/browser/agent-stream-smoothness.spec.ts
index 74069d3561..0146582755 100644
--- a/packages/app/e2e/browser/agent-stream-smoothness.spec.ts
+++ b/packages/app/e2e/browser/agent-stream-smoothness.spec.ts
@@ -1,6 +1,13 @@
import { test, expect } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
-import { startRunningMockAgent } from "../support/helpers/composer";
+import { startRunningMockAgent, submitMessage } from "../support/helpers/composer";
+import {
+ seedLongMockAgentTimeline,
+ openAgentTimeline,
+ scrollTimelineUntilOlderHistoryIsReachable,
+ scrollTimelineToNewestLoadedEdge,
+ expectTimelinePromptVisible,
+} from "../support/helpers/timeline-pagination";
import {
sampleStreamFrames,
summarizeStreamSmoothness,
@@ -102,6 +109,40 @@ agentStreamPerfDescribe("Agent stream smoothness", () => {
await agent.cleanup();
}
});
+
+ test("keeps streaming work bounded after revealing older history", async ({ page }, testInfo) => {
+ test.setTimeout(120_000);
+ await page.addInitScript(() => {
+ Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
+ });
+ const agent = await seedLongMockAgentTimeline({ turns: 80, liveTurns: "thirty-minute-stream" });
+ try {
+ await openAgentTimeline(page, agent);
+ await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
+ await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
+ await expectTimelinePromptVisible(page, agent.oldestPrompt);
+ await scrollTimelineToNewestLoadedEdge(page);
+ await submitMessage(page, "Stream a long answer after reading older messages.");
+ await awaitAssistantMessage(page);
+
+ for (let sample = 0; sample < 3; sample += 1) {
+ await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
+ const frames = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
+ const smoothness = summarizeStreamSmoothness(frames, SAMPLE_WINDOW_MS);
+ const react = summarizeReactCommits(await readStreamReactCommits(page));
+ await testInfo.attach(`revealed-history-stream-${sample}`, {
+ body: JSON.stringify({ smoothness, react }, null, 2),
+ contentType: "application/json",
+ });
+ console.log(`[perf] Revealed history stream: ${JSON.stringify({ smoothness, react })}`);
+ expect(smoothness.growthFrames).toBeGreaterThan(20);
+ expect(react.count).toBeGreaterThan(20);
+ expect(react.actualDurationP95Ms).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
+ }
+ } finally {
+ await agent.cleanup();
+ }
+ });
});
async function readStreamReactCommits(
diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts
index 5f70bdafe3..841314343f 100644
--- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts
+++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts
@@ -16,6 +16,7 @@ import {
} from "../support/helpers/assistant-images";
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
+import path from "node:path";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
},
});
+test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
+ imageWorkspace: workspace,
+ page,
+}) => {
+ test.setTimeout(120_000);
+ const image = await createSmallAssistantPng(workspace, {
+ alt: "Angle destination image",
+ fileName: "My image Мои.png",
+ });
+ const agent = await createSettledMockAgent(workspace, "Encoded image paths");
+ await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
+ await openAssistantImageTimeline(page, agent);
+ await expectAssistantImageRendered(page, image);
+
+ const encodedImage = { ...image, alt: "Encoded destination image" };
+ await emitSettledAssistantImage(
+ workspace.client,
+ agent,
+ encodedImage,
+ encodeURI(path.join(workspace.repoPath, image.relativePath)),
+ );
+ await expectAssistantImageRendered(page, encodedImage);
+
+ const literalPercentImage = await createSmallAssistantPng(workspace, {
+ alt: "Literal percent filename image",
+ fileName: "literal%20name.png",
+ });
+ await emitSettledAssistantImage(
+ workspace.client,
+ agent,
+ literalPercentImage,
+ encodeURI(literalPercentImage.relativePath),
+ );
+ await expectAssistantImageRendered(page, literalPercentImage);
+});
+
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts
index feceed7869..73c662124f 100644
--- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts
+++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts
@@ -133,6 +133,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new
).toHaveLength(1);
});
+test("scroll detector follows the entered image through resize and virtual offset correction", () => {
+ for (const height of [225, 1632]) {
+ const before: ScrollFrame = {
+ at: 1000,
+ scrollTop: 2208,
+ scrollHeight: 5000,
+ viewportHeight: 516,
+ virtualized: true,
+ loading: false,
+ rows: [
+ { id: "image", top: -704, height: 560 },
+ { id: "caption", top: -144, height: 100 },
+ { id: "old-reader", top: -44, height: 127 },
+ ],
+ anchor: "old-reader",
+ wheelTotal: 0,
+ lastWheelAt: 0,
+ inputFinishedAt: null,
+ imageLoads: 0,
+ mounted: 3,
+ unmounted: 0,
+ };
+ const resize = height - 560;
+ const after: ScrollFrame = {
+ ...before,
+ at: 1060,
+ // The virtualizer corrects 110px of estimates while the wheel moves 160px.
+ scrollTop: 1938,
+ scrollHeight: 5000 + resize - 110,
+ wheelTotal: 160,
+ lastWheelAt: 1020,
+ imageLoads: 1,
+ rows: [
+ { id: "image", top: -544, height },
+ { id: "caption", top: 16 + resize, height: 100 },
+ { id: "old-reader", top: 116 + resize, height: 127 },
+ ],
+ };
+ expect(findScrollJumps([before, after])).toEqual([]);
+ // Resizing must not excuse additional viewport movement.
+ expect(
+ findScrollJumps([
+ before,
+ {
+ ...after,
+ rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
+ },
+ ]),
+ ).toHaveLength(1);
+ }
+});
+
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
diff --git a/packages/app/e2e/browser/appearance-font-family.spec.ts b/packages/app/e2e/browser/appearance-font-family.spec.ts
new file mode 100644
index 0000000000..6ec8600017
--- /dev/null
+++ b/packages/app/e2e/browser/appearance-font-family.spec.ts
@@ -0,0 +1,48 @@
+import { expect, test } from "../support/fixtures";
+import type { Page } from "@playwright/test";
+import { openSettingsSection } from "../support/helpers/settings";
+
+async function openAppearance(page: Page) {
+ await page.goto("/settings");
+ await openSettingsSection(page, "appearance");
+}
+
+async function setFontFamily(page: Page, name: string, family: string) {
+ const input = page.getByRole("textbox", { name });
+ await input.fill(family);
+ await input.press("Tab");
+}
+
+for (const name of ["Code font family", "Interface font family"]) {
+ test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
+ await openAppearance(page);
+ await setFontFamily(page, name, "'Fira Code', monospace");
+ await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
+
+ await setFontFamily(page, name, "'foo");
+ await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
+ await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
+ await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
+
+ await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
+ await page.reload();
+ await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
+ await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
+ await setFontFamily(page, name, "");
+ await expect(page.getByRole("textbox", { name })).toHaveValue("");
+ });
+}
+
+test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "@paseo:app-settings",
+ JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
+ );
+ });
+ await openAppearance(page);
+ await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
+ await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
+ await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
+ await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
+});
diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts
index 8df71f0911..b0d86e164e 100644
--- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts
+++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
- await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
+ await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
diff --git a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts
index 89ee10af22..bc65ae65de 100644
--- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts
+++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts
@@ -89,9 +89,11 @@ async function dismissStackedModelPickerWithBackdrop(page: Page) {
.getByTestId("agent-controls-settings-list")
.getByRole("button", { name: /Select model/ })
.click();
- await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
+ await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeInViewport();
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
- await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
+ // In CI, Gorhom retains the dismissed sheet below the viewport.
+ // Dismissal is complete when it leaves the displayed surface.
+ await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
}
@@ -139,7 +141,7 @@ test.describe("mobile bottom sheet reopen", () => {
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
- await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
+ await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
});
} finally {
await session.cleanup();
diff --git a/packages/app/e2e/browser/changes-first-edit.spec.ts b/packages/app/e2e/browser/changes-first-edit.spec.ts
new file mode 100644
index 0000000000..72c3890936
--- /dev/null
+++ b/packages/app/e2e/browser/changes-first-edit.spec.ts
@@ -0,0 +1,122 @@
+import { appendFile } from "node:fs/promises";
+import path from "node:path";
+import type { Page } from "@playwright/test";
+import { test, expect } from "../support/fixtures";
+import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
+
+function changesTree(page: Page) {
+ return page.getByTestId("changes-tree-panel").filter({ visible: true });
+}
+
+async function expectEmptyChanges(page: Page) {
+ await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
+}
+
+async function editReadme(repoPath: string, text: string) {
+ await appendFile(path.join(repoPath, "README.md"), text);
+}
+
+async function expectReadmeChanges(page: Page, additions: number) {
+ await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(changesTree(page)).toContainText(`+${additions}`);
+}
+
+async function openEmptyDiff(page: Page) {
+ await changesTree(page).getByTestId("changes-options-menu").click();
+ await page.getByTestId("changes-open-tab").click();
+ await expect(
+ page
+ .getByTestId("working-diff-panel")
+ .filter({ visible: true })
+ .getByText("No changes to display", { exact: true }),
+ ).toBeVisible();
+}
+
+async function expectReadmeDiff(page: Page, additions: number) {
+ await expect(
+ page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
+ ).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
+}
+
+async function openDiffFromNewTabMenu(page: Page) {
+ await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
+ await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
+ await expect(
+ page
+ .getByTestId("working-diff-panel")
+ .filter({ visible: true })
+ .getByText("No changes to display", { exact: true }),
+ ).toBeVisible();
+}
+
+async function refreshDiff(page: Page) {
+ await page
+ .getByTestId("working-diff-panel")
+ .filter({ visible: true })
+ .getByTestId("changes-refresh")
+ .click();
+}
+
+async function reloadWorkspace(page: Page) {
+ await page.reload();
+}
+
+test("Changes follows the first edit to a clean workspace without reloading", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace();
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await workspace.navigateTo();
+ await openChangesTreePanel(page);
+ await expectEmptyChanges(page);
+
+ await editReadme(workspace.repoPath, "first edit\n");
+ await expectReadmeChanges(page, 1);
+
+ await editReadme(workspace.repoPath, "second edit\n");
+ await expectReadmeChanges(page, 2);
+});
+
+test("the full diff follows the first edit to a clean workspace without reloading", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace();
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await workspace.navigateTo();
+ await openChangesTreePanel(page);
+ await expectEmptyChanges(page);
+ await openEmptyDiff(page);
+
+ await editReadme(workspace.repoPath, "first edit\n");
+ await expectReadmeDiff(page, 1);
+
+ await editReadme(workspace.repoPath, "second edit\n");
+ await expectReadmeDiff(page, 2);
+});
+
+test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace();
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await workspace.navigateTo();
+ await openDiffFromNewTabMenu(page);
+
+ await editReadme(workspace.repoPath, "first edit\n");
+ await expectReadmeDiff(page, 1);
+
+ await editReadme(workspace.repoPath, "second edit\n");
+ await expectReadmeDiff(page, 2);
+
+ await refreshDiff(page);
+ await expectReadmeDiff(page, 2);
+ await reloadWorkspace(page);
+ await expectReadmeDiff(page, 2);
+ await editReadme(workspace.repoPath, "third edit\n");
+ await expectReadmeDiff(page, 3);
+});
diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts
index 637d1b633b..377adbb3b6 100644
--- a/packages/app/e2e/browser/changes-pane.spec.ts
+++ b/packages/app/e2e/browser/changes-pane.spec.ts
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
- await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
+ await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts
index 9dd3c33313..640b141f53 100644
--- a/packages/app/e2e/browser/chat-outline.spec.ts
+++ b/packages/app/e2e/browser/chat-outline.spec.ts
@@ -25,6 +25,12 @@ import {
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
+import {
+ attachPromptTimelineEvidence,
+ expectPromptsDrawnByPlugin,
+ expectVirtualizedPluginPromptVisible,
+ withPromptTimelinePlugin,
+} from "../support/helpers/plugin-timeline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => {
}
});
+ test("jumps to prompts a timeline plugin draws", async ({ page }) => {
+ test.setTimeout(180_000);
+ const agent = await seedLongMockAgentTimeline({ turns: 80 });
+ try {
+ await withPromptTimelinePlugin(async () => {
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await openAgentTimeline(page, agent);
+ await expectChatOutlinePrompts(page, 80);
+ await expectPromptsDrawnByPlugin(page);
+ await expectTimelinePromptNotMounted(page, agent.prompts[39]);
+
+ // Unloaded, then loaded, then near the newest edge.
+ await clickChatOutlineRowEdge(page, 40);
+ await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
+ await attachPromptTimelineEvidence(page, test.info());
+ await clickChatOutlineRowEdge(page, 45);
+ await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
+ await clickChatOutlineRowEdge(page, 78);
+ await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
+ await expectPromptsDrawnByPlugin(page);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+ });
+
+ test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
+ page,
+ }) => {
+ test.setTimeout(180_000);
+ const agent = await seedLongMockAgentTimeline({ turns: 80 });
+ const prompt = agent.prompts[29]!;
+ try {
+ await withPromptTimelinePlugin(async () => {
+ const history = await holdOlderHistoryPages(page, agent);
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await openAgentTimeline(page, agent);
+ await expectChatOutlinePrompts(page, 80);
+ await scrollThroughOlderHistoryPages(page, 2, history);
+ await scrollTimelineToNewestLoadedEdge(page);
+ await expectTimelinePromptNotMounted(page, prompt);
+ const requestsBeforeJump = history.requestCount();
+
+ await clickChatOutlineRowEdge(page, 30);
+ await expectTimelinePromptLandedBelowTop(page, prompt);
+ await expectVirtualizedPluginPromptVisible(page, prompt);
+ await attachPromptTimelineEvidence(page, test.info());
+ expect(history.requestCount()).toBe(requestsBeforeJump);
+ }, [prompt]);
+ } finally {
+ await agent.cleanup();
+ }
+ });
+
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
diff --git a/packages/app/e2e/browser/compact-workspace-header.spec.ts b/packages/app/e2e/browser/compact-workspace-header.spec.ts
new file mode 100644
index 0000000000..be26dbb8d1
--- /dev/null
+++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts
@@ -0,0 +1,192 @@
+import type { Page, TestInfo } from "@playwright/test";
+import { test, expect } from "../support/fixtures";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { openAgentRoute } from "../support/helpers/mock-agent";
+import { getServerId } from "../support/helpers/server-id";
+import { expectOpenTabCount } from "../support/helpers/sheet";
+import {
+ closeCompactWorkspaceMenu,
+ goBackInMenu,
+ menuRow,
+ openCompactWorkspaceMenu,
+ openRunningScriptPage,
+ openScriptsPage,
+ runScriptFromScriptsPage,
+ scriptRow,
+ scriptsMenuRow,
+} from "../support/helpers/compact-workspace-scripts";
+
+const labels = {
+ long: {
+ title: "workspace-with-a-very-long-release-and-investigation-name",
+ project: "project-with-a-very-long-customer-integration-name",
+ },
+ short: { title: "main", project: "Paseo" },
+};
+
+async function openNamedWorkspace(page: Page, names: typeof labels.long) {
+ const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
+ try {
+ await workspace.client.renameProject(workspace.projectId, names.project);
+ const agent = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Header layout check",
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ });
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
+ await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
+ await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
+ await expect(
+ page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
+ ).toHaveCount(1);
+ return workspace;
+ } catch (error) {
+ await workspace.cleanup();
+ throw error;
+ }
+}
+
+async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
+ const menu = page.getByTestId("menu-button").filter({ visible: true });
+ const title = page.getByTestId("workspace-header-title");
+ const project = page.getByTestId("workspace-header-subtitle");
+ const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
+ const trigger = page.getByRole("button", { name: /Switch tabs/ });
+ const measure = async () => ({
+ menu: await menu.boundingBox(),
+ title: await title.boundingBox(),
+ project: await project.boundingBox(),
+ badge: await badge.boundingBox(),
+ icon: await badge.locator("svg").boundingBox(),
+ trigger: await trigger.boundingBox(),
+ titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
+ projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
+ });
+ await page.screenshot({ path: info.outputPath("header.png") });
+ await expect(async () => {
+ const bounds = await measure();
+ expect(bounds.menu).not.toBeNull();
+ expect(bounds.title).not.toBeNull();
+ expect(bounds.project).not.toBeNull();
+ expect(bounds.badge).not.toBeNull();
+ expect(bounds.icon).not.toBeNull();
+ expect(bounds.trigger).not.toBeNull();
+ // The labels sit between the menu button and the actions without overlapping either.
+ expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
+ expect(bounds.project!.x).toBe(bounds.title!.x);
+ for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
+ expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
+ }
+ expect(bounds.project!.width).toBeGreaterThan(20);
+ // Long labels truncate instead of squeezing the host icon.
+ expect(bounds.icon!.width).toBe(bounds.icon!.height);
+ expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
+ expect(bounds.titleTruncated).toBe(long);
+ expect(bounds.projectTruncated).toBe(long);
+ }).toPass({ timeout: 5_000 });
+ await info.attach("header-bounds", {
+ body: JSON.stringify(await measure(), null, 2),
+ contentType: "application/json",
+ });
+}
+
+for (const width of [320, 390]) {
+ for (const kind of ["long", "short"] as const) {
+ test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
+ await page.setViewportSize({ width, height: 844 });
+ const workspace = await openNamedWorkspace(page, labels[kind]);
+ try {
+ await expectHeaderContentsFit(page, info, kind === "long");
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+ }
+}
+
+const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"';
+const WEB_SERVER =
+ "node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\"";
+
+async function openWorkspaceWithScripts(page: Page) {
+ const workspace = await seedWorkspace({
+ repoPrefix: "compact-scripts-",
+ repo: {
+ paseoConfig: {
+ scripts: {
+ lint: { type: "script", command: KEEP_ALIVE },
+ web: { type: "service", command: WEB_SERVER },
+ },
+ },
+ },
+ });
+ try {
+ const agent = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Scripts menu check",
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ });
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
+ return workspace;
+ } catch (error) {
+ await workspace.cleanup();
+ throw error;
+ }
+}
+
+test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const workspace = await openWorkspaceWithScripts(page);
+ try {
+ await expectOpenTabCount(page, 1);
+
+ await test.step("nothing running: the Scripts row has no value", async () => {
+ await openCompactWorkspaceMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts");
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => {
+ await runScriptFromScriptsPage(page, "lint");
+ await expectOpenTabCount(page, 2);
+ await openCompactWorkspaceMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("two running scripts are counted on the root row", async () => {
+ await runScriptFromScriptsPage(page, "web");
+ await expectOpenTabCount(page, 3);
+ await openCompactWorkspaceMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/);
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("stopping a script keeps its page open in the stopped state", async () => {
+ await openRunningScriptPage(page, "web");
+ await expect(menuRow(page, "View service")).toBeVisible();
+ await menuRow(page, "Stop").click();
+ await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 });
+ await expect(menuRow(page, "Stop")).toHaveCount(0);
+
+ await goBackInMenu(page);
+ await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/);
+ await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/);
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("the root row names the one script still running", async () => {
+ await openScriptsPage(page);
+ await goBackInMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
new file mode 100644
index 0000000000..77a2a0882b
--- /dev/null
+++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
@@ -0,0 +1,154 @@
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { expect, test } from "../support/fixtures";
+import { expectComposerVisible } from "../support/helpers/composer";
+import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import {
+ expectSubagentRowVisible,
+ openSubagentFromCompactTrack,
+ seedSubagent,
+} from "../support/helpers/subagents";
+import {
+ closeSheet,
+ expectOpenTabCount,
+ openSheetAsTab,
+ expectDeclinedDiscardPrompt,
+ expectSheetAtSnap,
+ replaceSheetFileText,
+ selectTabFromSwitcher,
+ sheet,
+ sheetHeader,
+ tapSheetBackdrop,
+} from "../support/helpers/sheet";
+
+const FILE_LINK = "linked.ts:3";
+
+function promptMentioning(target: string): string {
+ return [
+ "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
+ "Return JSON only with fields 'title' and 'branch'.",
+ "",
+ "",
+ `Open \`${target}\` now`,
+ " ",
+ ].join("\n");
+}
+
+test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const workspace = await seedMockAgentWorkspace({
+ repoPrefix: "compact-workspace-sheet-",
+ title: "Sheet parent",
+ initialPrompt: promptMentioning(FILE_LINK),
+ });
+ try {
+ const gate = await installDaemonWebSocketGate(page);
+ const linkedPath = path.join(workspace.cwd, "linked.ts");
+ await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
+ const child = await seedSubagent(
+ { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
+ { parentId: workspace.agentId, title: "Sheet child" },
+ );
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await expectOpenTabCount(page, 1);
+
+ await test.step("a chat file link opens the file in a sheet", async () => {
+ const fileLink = page.getByText(FILE_LINK, { exact: true });
+ await expect(fileLink).toBeVisible({ timeout: 15_000 });
+ await fileLink.click();
+
+ const fileSheet = sheet(page, "linked.ts");
+ await expect(fileSheet).toBeVisible();
+ await expect(fileSheet.getByText("three")).toBeVisible();
+ await expectSheetAtSnap(page, "linked.ts");
+ await expect(
+ sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
+ ).toHaveCount(0);
+ await expectOpenTabCount(page, 1);
+ await closeSheet(page, "linked.ts");
+ });
+
+ await test.step("a subagent opens in a sheet over the subagents list", async () => {
+ await openSubagentFromCompactTrack(page, child.id);
+
+ const childSheet = sheet(page, child.title);
+ await expect(childSheet).toBeVisible();
+ await expectSheetAtSnap(page, child.title);
+ await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
+ // The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
+ // seeded agent has no context usage yet.
+ await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
+ "Ten second stream · Low · Load test",
+ );
+ await expectOpenTabCount(page, 1);
+ });
+
+ await test.step("closing the subagent's sheet uncovers the list", async () => {
+ await closeSheet(page, child.title);
+ await expectSubagentRowVisible(page, child.id);
+ });
+
+ await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
+ await page.getByTestId(`subagents-track-row-${child.id}`).click();
+ await expectSheetAtSnap(page, child.title);
+ await openSheetAsTab(page, child.title);
+ await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
+ await expectOpenTabCount(page, 2);
+ await expectComposerVisible(page);
+ });
+
+ await test.step("the list the subagent was opened from closed with its tab", async () => {
+ await selectTabFromSwitcher(page, "Sheet parent");
+ await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
+ });
+
+ await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
+ await page
+ .getByRole("button", { name: /Switch tabs/ })
+ .filter({ visible: true })
+ .click();
+ await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
+ const menuBase = `workspace-tab-menu-agent_${child.id}`;
+ await page.getByTestId(`${menuBase}-trigger`).click();
+ await page.getByTestId(`${menuBase}-close`).click();
+ await expectOpenTabCount(page, 1);
+ // Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
+ // tapped once the row menu's own sheet has gone.
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
+ await tapSheetBackdrop(page);
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+
+ await openSubagentFromCompactTrack(page, child.id);
+ await expectSheetAtSnap(page, child.title);
+ await closeSheet(page, child.title);
+ await expectSubagentRowVisible(page, child.id);
+ await tapSheetBackdrop(page);
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+ });
+
+ await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
+ await page.getByText(FILE_LINK, { exact: true }).click();
+ const fileSheet = sheet(page, "linked.ts");
+ await expect(fileSheet).toBeVisible();
+ // Hold the autosave so the edit stays unsaved.
+ gate.holdNextClientRequest("fs.file.write.request");
+ await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
+ await gate.waitForHeldClientRequest();
+
+ await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
+ await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
+ await expectDeclinedDiscardPrompt(page, () =>
+ sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
+ );
+ await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
+
+ gate.releaseHeldClientRequest();
+ await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts
index d30e69b18c..d83046d8b2 100644
--- a/packages/app/e2e/browser/composer-autocomplete.spec.ts
+++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts
@@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
-import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
+import {
+ closeMobileAgentSidebar,
+ expectMobileAgentSidebarHidden,
+ expectMobileAgentSidebarVisible,
+ openMobileAgentSidebar,
+} from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
@@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
-async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
- return page.evaluate(
- ([pointX, pointY]) => {
- const element = document.elementFromPoint(pointX, pointY);
- return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
- },
- [x, y],
- );
-}
-
async function installListCommandsStub(page: Page): Promise {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
timeout: 30_000,
});
- await page.getByRole("button", { name: "Open menu" }).click();
+ // The sidebar covers the composer, so the composer gives up its popover while the
+ // sidebar is open and brings it back once the sidebar closes.
+ await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
+ await expect(popover).toHaveCount(0);
- const popoverBox = await popover.boundingBox();
- expect(popoverBox).not.toBeNull();
-
- const topTestId = await getTopTestIdAtPoint(
- page,
- popoverBox!.x + popoverBox!.width / 2,
- popoverBox!.y + popoverBox!.height / 2,
- );
-
- expect(topTestId).not.toBe("composer-autocomplete-popover");
+ await closeMobileAgentSidebar(page);
+ await expectMobileAgentSidebarHidden(page);
+ await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts
index 455c58180a..00705faccd 100644
--- a/packages/app/e2e/browser/composer-diff-stat.spec.ts
+++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts
@@ -1,7 +1,14 @@
+import type { Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
+import {
+ fillComposerDraft,
+ expectComposerDraft,
+ attachFileFromMenu,
+} from "../support/helpers/composer";
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
await composerChangesPill(page).click();
}
-async function seedChangedAgent(repoPrefix: string) {
+async function seedChangedAgent(
+ repoPrefix: string,
+ featureValues?: Record,
+ files: Array<{ path: string; content: string }> = [],
+) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
+ featureValues,
title: "Composer diff stat",
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
- files: [{ path: ".gitignore", content: "/remote.git/\n" }],
+ files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
},
});
try {
@@ -156,3 +168,691 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
await workspace.cleanup();
}
});
+
+async function tapFirstAddedDiffLine(
+ page: Page,
+ surface: Locator = page.getByTestId("working-diff-panel"),
+) {
+ const body = surface.getByTestId("diff-file-0-body");
+ await expect(body).toBeVisible({ timeout: 30000 });
+ const bounds = await body.boundingBox();
+ if (!bounds) throw new Error("Expanded diff body has no bounds");
+ const fontSize = await surface
+ .getByTestId("git-diff-canvas")
+ .evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
+ await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
+}
+
+async function openReviewDiff(page: Page, workspace: Awaited>) {
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
+ if (await page.getByTestId("workspace-tab-working_diff").count()) {
+ await selectDiffTab(page);
+ } else {
+ await revealComposerChangesInExplorer(page);
+ await openComposerDiff(page);
+ }
+ await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
+}
+
+async function saveReviewComment(page: Page, body: string) {
+ await page.getByRole("textbox", { name: "Review comment" }).fill(body);
+ await page.getByRole("button", { name: "Save review comment", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
+async function cancelReviewComment(page: Page) {
+ await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
+test.describe("review comments", () => {
+ test.use({ hasTouch: true });
+
+ test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("compact-review-comment-");
+ try {
+ await test.step("Open a compact sheet with reachable input and actions", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await tapFirstAddedDiffLine(page);
+ await expectCompactCommentSheet(page);
+ });
+ await test.step("Save, discard an edit, then save an updated comment", async () => {
+ await saveReviewComment(page, " Please simplify this. ");
+ await expectSavedReview(page, "Please simplify this.");
+ await editSavedReview(page, "Please simplify this.");
+ await fillReviewComment(page, "Discard this edit");
+ await cancelReviewComment(page);
+ await expectSavedReview(page, "Please simplify this.");
+ await editSavedReview(page, "Please simplify this.");
+ await saveReviewComment(page, "Updated comment");
+ await expectSavedReview(page, "Updated comment");
+ });
+ await test.step("Discard a new comment and retain the saved one after reload", async () => {
+ await tapFirstAddedDiffLine(page);
+ await fillReviewComment(page, "Discard new comment");
+ await cancelReviewComment(page);
+ await expectReviewAbsent(page, "Discard new comment");
+ await reloadReview(page);
+ await expectSavedReview(page, "Updated comment");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("wide review comments retain inline editing", async ({ page }) => {
+ const workspace = await seedChangedAgent("wide-review-comment-");
+ try {
+ await test.step("Save and cancel an inline edit on a wide screen", async () => {
+ await openReviewDiff(page, workspace);
+ await tapFirstAddedDiffLine(page);
+ await expectInlineCommentEditor(page);
+ await saveReviewComment(page, "Wide comment");
+ await expectSavedReview(page, "Wide comment");
+ await editSavedReview(page, "Wide comment");
+ await expectInlineCommentEditor(page);
+ await cancelReviewComment(page);
+ await expectSavedReview(page, "Wide comment");
+ });
+ await test.step("The existing composer still sends the review", async () => {
+ await sendReviewThroughComposer(page, workspace.agentId);
+ await selectDiffTab(page);
+ await expectReviewAbsent(page, "Wide comment");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+});
+
+async function sendReviewThroughComposer(page: Page, agentId: string) {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
+ await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
+ await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
+ await page.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
+ await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
+}
+
+async function addSavedReview(
+ page: Page,
+ body: string,
+ surface: Locator = page.getByTestId("working-diff-panel"),
+) {
+ await tapFirstAddedDiffLine(page, surface);
+ await saveReviewComment(page, body);
+ await expectSavedReview(page, body, surface);
+}
+
+async function expectSavedReview(
+ page: Page,
+ body: string,
+ surface: Locator = page.getByTestId("working-diff-panel"),
+) {
+ await expect(surface.getByText(body, { exact: true })).toBeVisible();
+}
+
+async function editSavedReview(page: Page, expectedBody: string) {
+ await page
+ .getByTestId("working-diff-panel")
+ .getByRole("button", { name: "Edit review comment", exact: true })
+ .click();
+ await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
+}
+
+async function sendFeedback(page: Page, count: number) {
+ await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
+}
+
+async function expectFeedbackSent(page: Page, recipient: string) {
+ await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
+ await expect(page.getByTestId("working-diff-panel")).toBeVisible();
+}
+
+test.describe("send review feedback", () => {
+ test.use({ hasTouch: true });
+
+ test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-single-");
+ try {
+ await test.step("Save a review without changing the chat draft", async () => {
+ await openReviewDiff(page, workspace);
+ await selectAgentTab(page, workspace.agentId);
+ await fillComposerDraft(page, "Keep this thought");
+ await attachFileFromMenu(page, {
+ name: "unrelated.txt",
+ mimeType: "text/plain",
+ buffer: Buffer.from("Unrelated draft attachment"),
+ });
+ await selectDiffTab(page);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Please simplify this.");
+ });
+ await test.step("Send directly and stay on the diff", async () => {
+ await sendFeedback(page, 1);
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewAbsent(page, "Please simplify this.");
+ await captureFeedback(page, "single");
+ });
+ await test.step("Return to the untouched composer draft", async () => {
+ await showWideLayout(page);
+ await selectAgentTab(page, workspace.agentId);
+ await expectComposerDraft(page, "Keep this thought");
+ await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
+ await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+ test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-shared-pending-");
+ const gate = await gateNextAgentMessage(page);
+ try {
+ await test.step("Send feedback from the Diff tab and hold its real request", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "One draft across surfaces");
+ await sendFeedback(page, 1);
+ await gate.waitForRequest();
+ });
+ await test.step("The compact Changes surface observes the same pending operation", async () => {
+ await revealCompactChanges(page);
+ await expect(
+ compactReviewSurface(page).getByRole("button", {
+ name: "Sending feedback (1)",
+ exact: true,
+ }),
+ ).toBeDisabled();
+ expect(gate.requestCount()).toBe(1);
+ gate.accept();
+ await expect(
+ compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-editor-ownership-");
+ const gate = await gateNextAgentMessage(page);
+ try {
+ await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
+ await openReviewDiff(page, workspace);
+ await tapFirstAddedDiffLine(page);
+ await fillReviewComment(page, "Body across presentations");
+ await showCompactLayout(page);
+ await expectReviewEditorBody(page, "Body across presentations");
+ await showWideLayout(page);
+ await expectReviewEditorBody(page, "Body across presentations");
+ await saveReviewComment(page, "Body across presentations");
+ });
+ await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
+ await sendFeedback(page, 1);
+ await gate.waitForRequest();
+ await editSavedReview(page, "Body across presentations");
+ await fillReviewComment(page, "Keep this unsaved edit");
+ gate.accept();
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewEditorBody(page, "Keep this unsaved edit");
+ await showCompactLayout(page);
+ await expectReviewEditorBody(page, "Keep this unsaved edit");
+ await saveReviewComment(page, "Keep this unsaved edit");
+ await expectSavedReview(page, "Keep this unsaved edit");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
+ const workspace = await seedMultiFileReview();
+ try {
+ await test.step("Save compact feedback and retain both floating actions", async () => {
+ await openCompactReviewExplorer(page, workspace);
+ await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
+ await expectReviewNavigationReachable(page);
+ });
+ await test.step("Jump to another file without covering the feedback action", async () => {
+ await jumpToReviewFile(page, "z-last.ts");
+ await scrollReviewToLastLine(page);
+ await expectReviewNavigationReachable(page);
+ await captureFeedback(page, "navigation-compact");
+ });
+ await test.step("Wide file navigation and feedback remain reachable", async () => {
+ await openReviewDiff(page, workspace);
+ await navigateWideReviewFile(page, "README.md");
+ await expectFeedbackReachable(page);
+ await expectSavedReview(page, "Keep file navigation available");
+ await captureFeedback(page, "navigation-wide");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("feedback sends only matching comments and preserves orphaned comments", async ({
+ page,
+ }) => {
+ const workspace = await seedMultiFileReview();
+ try {
+ await test.step("A saved comment whose diff disappears has no sendable context", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Retain this orphan");
+ await setReadmeDiff(workspace, false);
+ await expectUnmatchedFeedback(page);
+ });
+ await test.step("Send the matching subset with an accurate count", async () => {
+ await addSavedReview(page, "Send this matching comment");
+ await sendFeedback(page, 1);
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewAbsent(page, "Send this matching comment");
+ await expectUnmatchedFeedback(page);
+ });
+ await test.step("Restoring the missing diff reveals the unsent comment", async () => {
+ await setReadmeDiff(workspace, true);
+ await expectSavedReview(page, "Retain this orphan");
+ await expectFeedbackReachable(page);
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
+ const workspace = await seedChangedAgent("review-feedback-zero-");
+ try {
+ await test.step("Close the agent tab while keeping the review", async () => {
+ await openReviewDiff(page, workspace);
+ await addSavedReview(page, "Keep until an agent is opened");
+ await closeAgentTab(page, workspace.agentId);
+ });
+ await test.step("The disabled action explains why in wide and compact layouts", async () => {
+ await expectFeedbackUnavailable(page);
+ await captureFeedback(page, "zero-wide");
+ await showCompactLayout(page);
+ await expectFeedbackUnavailable(page);
+ await expectSavedReview(page, "Keep until an agent is opened");
+ await captureFeedback(page, "zero-compact");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-multiple-");
+ const other = await seedChangedAgent("review-feedback-other-");
+ try {
+ const second = await createReviewRecipient(workspace, "Second reviewer");
+ const historical = await createReviewRecipient(workspace, "Closed historical agent");
+ await test.step("Open two local tabs and a tab in another workspace", async () => {
+ await openReviewDiff(page, other);
+ await addSavedReview(page, "Other workspace review");
+ await openReviewDiff(page, workspace);
+ await closeAgentTab(page, historical.id);
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
+ await selectDiffTab(page);
+ await addSavedReview(page, "Review for second agent");
+ });
+ await test.step("The wide menu includes only local open agents", async () => {
+ await captureWideFeedbackAppearance(page);
+ });
+ await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
+ await openFeedbackWithKeyboard(page);
+ await dismissRecipientMenu(page);
+ await expectFeedbackFocused(page);
+ await reopenFeedbackWithKeyboard(page);
+ await dismissRecipientMenu(page);
+ await expectFeedbackFocused(page);
+ });
+ await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
+ await showCompactLayout(page);
+ await captureFeedback(page, "cta-compact");
+ await sendFeedback(page, 1);
+ await expectRecipientMenu(page);
+ await captureFeedback(page, "menu-compact");
+ await chooseFeedbackRecipient(page, "Second reviewer");
+ await expectFeedbackSent(page, "Second reviewer");
+ await expectReviewAbsent(page, "Review for second agent");
+ });
+ await test.step("The other workspace's saved review remains", async () => {
+ await openReviewDiff(page, other);
+ await expectSavedReview(page, "Other workspace review");
+ });
+ } finally {
+ await workspace.cleanup();
+ await other.cleanup();
+ }
+ });
+
+ test("failed feedback remains editable and retries through the real provider", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
+ try {
+ await test.step("A provider rejection retains the saved review", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Initial feedback");
+ await sendFeedback(page, 1);
+ await expectFeedbackFailure(page);
+ await expectSavedReview(page, "Initial feedback");
+ await captureFeedback(page, "failure");
+ });
+ await test.step("Edit the retained feedback and retry successfully", async () => {
+ await editSavedReview(page, "Initial feedback");
+ await saveReviewComment(page, "Corrected feedback");
+ await sendFeedback(page, 1);
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewAbsent(page, "Corrected feedback");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("pending feedback blocks repeat submission and preserves edits and additions", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-pending-");
+ const gate = await gateNextAgentMessage(page);
+ try {
+ await test.step("Hold the real request while feedback is pending", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Sent version");
+ await sendFeedback(page, 1);
+ const request = await gate.waitForRequest();
+ expect(request.agentId).toBe(workspace.agentId);
+ expect(request.text).toBe("Please address this code review.");
+ expect(request.attachments).toMatchObject([
+ {
+ type: "review",
+ cwd: workspace.cwd,
+ comments: [
+ {
+ filePath: "README.md",
+ lineNumber: 2,
+ body: "Sent version",
+ context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
+ },
+ ],
+ },
+ ]);
+ await expectFeedbackPending(page);
+ });
+ await test.step("Save an edit and another comment before acknowledgement", async () => {
+ await editSavedReview(page, "Sent version");
+ await saveReviewComment(page, "Edited during send");
+ await addSavedReview(page, "Added during send");
+ await expectFeedbackPending(page);
+ expect(gate.requestCount()).toBe(1);
+ await captureFeedback(page, "pending");
+ });
+ await test.step("A real acknowledgement preserves both new versions", async () => {
+ gate.accept();
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectSavedReview(page, "Edited during send");
+ await expectSavedReview(page, "Added during send");
+ await expectReviewAbsent(page, "Sent version");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+});
+
+async function showCompactLayout(page: Page) {
+ await page.setViewportSize({ width: 390, height: 844 });
+}
+async function showWideLayout(page: Page) {
+ await page.setViewportSize({ width: 1400, height: 900 });
+}
+async function selectAgentTab(page: Page, agentId: string) {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
+}
+async function selectDiffTab(page: Page) {
+ await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
+}
+async function fillReviewComment(page: Page, body: string) {
+ await page.getByRole("textbox", { name: "Review comment" }).fill(body);
+}
+async function expectReviewAbsent(page: Page, body: string) {
+ await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
+ 0,
+ );
+}
+async function reloadReview(page: Page) {
+ await page.reload();
+ await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
+}
+async function expectInlineCommentEditor(page: Page) {
+ await expect(page.getByTestId("inline-review-editor")).toBeVisible();
+ await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
+}
+async function expectCompactCommentSheet(page: Page) {
+ await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
+ await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
+ for (const action of ["Save", "Cancel"])
+ await expect(
+ page.getByRole("button", { name: `${action} review comment`, exact: true }),
+ ).toBeInViewport({ ratio: 1 });
+ await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Save review comment", exact: true }),
+ ).toBeDisabled();
+}
+function feedbackButton(page: Page) {
+ return page.getByRole("button", { name: "Send feedback (1)", exact: true });
+}
+async function openFeedbackWithKeyboard(page: Page) {
+ await feedbackButton(page).focus();
+ await expectFeedbackFocused(page);
+ await reopenFeedbackWithKeyboard(page);
+}
+async function reopenFeedbackWithKeyboard(page: Page) {
+ await page.keyboard.press("Enter");
+ await expectRecipientMenu(page);
+ const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
+ await recipient.focus();
+ await expect(recipient).toBeFocused();
+}
+async function expectFeedbackFocused(page: Page) {
+ await expect(feedbackButton(page)).toBeFocused();
+}
+
+async function captureWideFeedbackAppearance(page: Page) {
+ for (const colorScheme of ["light", "dark"] as const) {
+ await page.emulateMedia({ colorScheme });
+ await expectSavedReview(page, "Review for second agent");
+ await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
+ await sendFeedback(page, 1);
+ await expectRecipientMenu(page);
+ await captureFeedback(page, `cta-wide-${colorScheme}-open`);
+ await dismissRecipientMenu(page);
+ }
+ await page.emulateMedia({ colorScheme: "light" });
+}
+
+async function captureFeedback(page: Page, name: string) {
+ await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
+}
+
+async function closeAgentTab(page: Page, agentId: string) {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
+ await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
+ await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
+}
+async function expectFeedbackUnavailable(page: Page) {
+ await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
+ await expect(
+ page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
+ ).toBeVisible();
+}
+async function createReviewRecipient(
+ workspace: Awaited>,
+ title: string,
+) {
+ return workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.cwd,
+ workspaceId: workspace.workspaceId,
+ title,
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ });
+}
+async function expectRecipientMenu(page: Page) {
+ for (const name of ["Composer diff stat", "Second reviewer"]) {
+ await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
+ }
+ await expect(page.getByRole("menuitem")).toHaveCount(2);
+}
+async function dismissRecipientMenu(page: Page) {
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("menuitem")).toHaveCount(0);
+}
+async function chooseFeedbackRecipient(page: Page, name: string) {
+ await page.getByRole("menuitem", { name, exact: true }).click();
+}
+async function expectFeedbackFailure(page: Page) {
+ await expect(
+ page.getByText("Requested mock prompt rejection", { exact: true }).first(),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
+}
+async function expectFeedbackPending(page: Page, count = 1) {
+ await expect(
+ page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
+ ).toBeDisabled();
+}
+
+async function seedMultiFileReview() {
+ const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
+ { path: "z-last.ts", content: "export const final = 0;\n" },
+ ]);
+ await writeFile(
+ path.join(workspace.cwd, "z-last.ts"),
+ Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
+ "\n",
+ );
+ await workspace.client.checkoutRefresh(workspace.cwd);
+ return workspace;
+}
+async function expectFeedbackReachable(page: Page) {
+ await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
+ { ratio: 1 },
+ );
+}
+async function expectReviewNavigationReachable(page: Page) {
+ await expectFeedbackReachable(page);
+ const jump = page.getByRole("button", { name: "Jump to file", exact: true });
+ await expect(jump).toBeInViewport({ ratio: 1 });
+ const navigationBounds = await jump.boundingBox();
+ const feedbackBounds = await page
+ .getByRole("button", { name: "Send feedback (1)", exact: true })
+ .boundingBox();
+ if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
+ expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
+}
+async function jumpToReviewFile(page: Page, name: string) {
+ await page.getByRole("button", { name: "Jump to file", exact: true }).click();
+ const sheet = page.getByTestId("changes-jump-to-file-sheet");
+ await page
+ .getByTestId("changes-file-tree")
+ .filter({ visible: true })
+ .getByText(name, { exact: true })
+ .click();
+ await expect(sheet).toHaveCount(0);
+ await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
+}
+async function navigateWideReviewFile(page: Page, name: string) {
+ const explorer = await ensureExplorerSidebar(page);
+ await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
+ await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
+}
+
+async function setReadmeDiff(
+ workspace: Awaited>,
+ changed: boolean,
+) {
+ await writeFile(
+ path.join(workspace.cwd, "README.md"),
+ changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
+ );
+ await workspace.client.checkoutRefresh(workspace.cwd);
+}
+async function expectUnmatchedFeedback(page: Page) {
+ await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
+ await expect(
+ page.getByText("Saved comments no longer match this diff.", { exact: true }),
+ ).toBeVisible();
+}
+
+function compactReviewSurface(page: Page) {
+ return page.getByTestId("explorer-content-area").filter({ visible: true });
+}
+async function openCompactReviewExplorer(
+ page: Page,
+ workspace: Awaited>,
+) {
+ await showWideLayout(page);
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await selectAgentTab(page, workspace.agentId);
+ await showCompactLayout(page);
+ await composerChangesPill(page).click();
+ await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
+}
+
+function reviewFileHeader(surface: Locator, name: string) {
+ return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
+}
+
+async function scrollReviewToLastLine(page: Page) {
+ const surface = compactReviewSurface(page);
+ const bounds = await surface.boundingBox();
+ if (!bounds) throw new Error("Compact review has no bounds");
+ await page.mouse.move(bounds.x + 120, bounds.y + 200);
+ await page.mouse.wheel(0, 10000);
+ await expect
+ .poll(async () => {
+ const body = await surface.getByTestId("diff-file-1-body").boundingBox();
+ const feedback = await page
+ .getByRole("button", { name: "Send feedback (1)", exact: true })
+ .boundingBox();
+ if (!body || !feedback) return false;
+ return body.y + body.height <= feedback.y;
+ })
+ .toBe(true);
+}
+
+async function revealCompactChanges(page: Page) {
+ await page.getByTestId("workspace-explorer-toggle").first().click();
+ await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
+ await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
+}
+async function expectReviewEditorBody(page: Page, body: string) {
+ await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
+ body,
+ );
+}
diff --git a/packages/app/e2e/browser/composer-email-mentions.spec.ts b/packages/app/e2e/browser/composer-email-mentions.spec.ts
new file mode 100644
index 0000000000..283fca26ca
--- /dev/null
+++ b/packages/app/e2e/browser/composer-email-mentions.spec.ts
@@ -0,0 +1,104 @@
+import { expect, test, type Page } from "../support/fixtures";
+import {
+ composerLocator,
+ expectComposerDraft,
+ expectComposerVisible,
+ fillComposerDraft,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
+ resizeComposerViewport,
+ selectComposerDraftRange,
+} from "../support/helpers/composer";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+
+// The popover has no semantic role; its rows are React Native Pressables.
+function fileSuggestions(page: Page) {
+ return page.getByTestId("composer-autocomplete-popover");
+}
+
+async function selectReadmeMention(page: Page): Promise {
+ await fillComposerDraft(page, "@README");
+ await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
+ await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, '"README.md"');
+}
+
+async function sendEmailIntact(page: Page, email: string): Promise {
+ await fillComposerDraft(page, email);
+ await expect(fileSuggestions(page)).not.toBeVisible();
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, "");
+ await expect(page.getByText(email, { exact: true })).toBeVisible();
+}
+
+for (const email of [
+ "user <12345+user@noreply.example.com>",
+ "alice@example.com",
+ "noreply+@example.com",
+]) {
+ test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-email-",
+ title: "Email mention regression",
+ repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
+ });
+ try {
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await test.step("complete a workspace file mention with Enter", async () => {
+ await selectReadmeMention(page);
+ });
+ await test.step("send an email without offering or inserting workspace files", async () => {
+ await sendEmailIntact(page, email);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+ });
+}
+
+test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-mention-",
+ title: "Fullscreen mention keys",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ const draft = "First line\nSecond line\n@README";
+ await fillComposerDraft(page, draft);
+ await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
+ await enterComposerFullscreen(page);
+ await expect(fileSuggestions(page)).not.toBeVisible();
+ await selectComposerDraftRange(page, draft.length, draft.length);
+ await composerLocator(page).press("ArrowUp");
+ await expect
+ .poll(() =>
+ composerLocator(page).evaluate((element) => {
+ const input = element as HTMLTextAreaElement;
+ return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
+ }),
+ )
+ .toBe(true);
+ await selectComposerDraftRange(page, draft.length, draft.length);
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, `${draft}\n`);
+ await exitComposerFullscreen(page);
+ await selectReadmeMention(page);
+ const commandDraft = "/cl\nSecond line\nThird line";
+ await fillComposerDraft(page, commandDraft);
+ await selectComposerDraftRange(page, 2, 2);
+ await composerLocator(page).press("ArrowRight");
+ await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
+ await enterComposerFullscreen(page);
+ await expect(fileSuggestions(page)).not.toBeVisible();
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts
index b79cfd9255..c81624ff7d 100644
--- a/packages/app/e2e/browser/composer-focus.spec.ts
+++ b/packages/app/e2e/browser/composer-focus.spec.ts
@@ -1,13 +1,127 @@
import { expect, test, type Page } from "../support/fixtures";
import {
+ blurComposerInput,
+ composerLocator,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
expectComposerDraft,
expectComposerFocused,
+ expectComposerFullscreenControls,
expectComposerNotFocused,
expectComposerVisible,
+ expectWorkspaceHeaderVisible,
+ resizeComposerViewport,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
+import {
+ closeMobileAgentSidebar,
+ closeMobileExplorer,
+ expectMobileAgentSidebarHidden,
+ expectMobileAgentSidebarVisible,
+ expectMobileExplorerHidden,
+ expectMobileExplorerVisible,
+ swipeOpenMobileAgentSidebar,
+ swipeOpenMobileExplorer,
+} from "../support/helpers/sidebar";
+import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
+import {
+ expectTimelinePromptPositionPreserved,
+ rememberTimelinePromptPosition,
+ seedLongMockAgentTimeline,
+ waitForTimelineGeometryToSettle,
+} from "../support/helpers/timeline-pagination";
+
+test("clicking chat text or background focuses the feed for keyboard paging", async ({
+ page,
+}, testInfo) => {
+ const agent = await seedLongMockAgentTimeline({ turns: 8 });
+
+ try {
+ await openAgentRoute(page, agent);
+ const composer = composerLocator(page);
+ const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
+ await composer.fill("Keep this draft");
+ await feed.getByText(agent.newestPrompt, { exact: true }).click();
+ await expect(feed).toBeFocused();
+
+ const beforePaging = await readScrollMetrics(page);
+ expect(beforePaging.offsetY).toBeGreaterThan(beforePaging.viewportHeight);
+ await page.keyboard.press("PageUp");
+ await expect
+ .poll(async () => (await readScrollMetrics(page)).offsetY)
+ .toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
+ await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
+ await page.keyboard.press("PageDown");
+ await expect
+ .poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
+ .toBeLessThanOrEqual(2);
+
+ await composer.click();
+ await expectComposerFocused(page);
+ await feed.click({ position: { x: 2, y: 40 } });
+ await expect(feed).toBeFocused();
+ await page.keyboard.press("PageUp");
+ await expect
+ .poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
+ .toBeGreaterThan(beforePaging.viewportHeight / 2);
+ await waitForTimelineGeometryToSettle(page);
+ const readingPrompt = agent.prompts[4];
+ await expect(feed.getByText(readingPrompt, { exact: true })).toBeInViewport();
+ const readingPosition = await rememberTimelinePromptPosition(page, readingPrompt);
+ await agent.client.sendAgentMessage(agent.agentId, "emit 2 coalesced agent stream updates");
+ await agent.client.waitForFinish(agent.agentId, 15_000);
+ // The daemon finishing does not mean its response has reached the browser.
+ await expect(feed.getByText(/stress-update-1/)).toHaveCount(1);
+ await waitForTimelineGeometryToSettle(page);
+ await expectTimelinePromptPositionPreserved(page, readingPosition);
+ await expectComposerDraft(page, "Keep this draft");
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("focusing the feed preserves assistant text selection and code copy controls", async ({
+ page,
+ context,
+}) => {
+ const sentence = "Select this assistant sentence.";
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "chat-focus-selection-",
+ title: "Chat focus selection",
+ initialPrompt: "Render text and code.",
+ featureValues: {
+ mockAssistantResponse: `${sentence}\n\n\`\`\`bash\necho example\n\`\`\``,
+ },
+ });
+
+ try {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await agent.client.waitForFinish(agent.agentId, 15_000);
+ await openAgentRoute(page, agent);
+ await composerLocator(page).fill("Keep this draft");
+
+ const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
+ await feed.getByText(sentence, { exact: true }).click({ clickCount: 3 });
+ await expect(feed).toBeFocused();
+ expect(await page.evaluate(() => window.getSelection()?.toString().trim())).toBe(sentence);
+ await page.keyboard.press("ControlOrMeta+c");
+ expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(sentence);
+
+ const code = feed.locator('[data-paseo-markdown-language="bash"]');
+ await code.hover();
+ await code.locator("[data-paseo-markdown-ignore]").click();
+ await expect(feed).not.toBeFocused();
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("echo example");
+ await expectComposerDraft(page, "Keep this draft");
+ } finally {
+ await agent.cleanup();
+ }
+});
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -56,6 +170,59 @@ test.describe("touch screen wide enough for the desktop layout", () => {
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
+ test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-touch-remount-",
+ title: "Touch remount",
+ });
+ try {
+ await openAgentRoute(page, agent);
+ await composerLocator(page).fill("Keep this draft through resize");
+ await expectComposerFocused(page);
+ await resizeComposerViewport(page, "portrait");
+ await expectComposerDraft(page, "Keep this draft through resize");
+ await expectComposerNotFocused(page);
+ await composerLocator(page).tap();
+ await expectComposerFocused(page);
+ } finally {
+ await agent.cleanup();
+ }
+ });
+
+ test("returning to an inactive inline composer does not restore focus on touch", async ({
+ page,
+ }) => {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
+ try {
+ const first = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "First touch chat",
+ });
+ const second = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Second touch chat",
+ });
+ await openWorkspaceWithAgents(page, [first, second]);
+ await composerLocator(page).fill("Keep this touch draft");
+ await expectComposerFocused(page);
+ // An external keyboard changes tabs without a pointer click blurring the
+ // editor first. The device still has a touch-primary focus policy.
+ await switchComposerTabWithKeyboard(page, "previous");
+ await expectComposerDraft(page, "");
+ await switchComposerTabWithKeyboard(page, "next");
+ await expectComposerDraft(page, "Keep this touch draft");
+ await expectComposerNotFocused(page);
+ await composerLocator(page).tap();
+ await expectComposerFocused(page);
+ await typeIntoFocusedComposer(page, " after return");
+ await expectComposerDraft(page, "Keep this touch draft after return");
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
test("opening an agent leaves the composer unfocused", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-touch-",
@@ -76,3 +243,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
}
});
});
+
+const COMPACT_PANELS = [
+ {
+ name: "agent list",
+ open: swipeOpenMobileAgentSidebar,
+ expectOpen: expectMobileAgentSidebarVisible,
+ close: closeMobileAgentSidebar,
+ expectClosed: expectMobileAgentSidebarHidden,
+ },
+ {
+ name: "Explorer",
+ open: swipeOpenMobileExplorer,
+ expectOpen: expectMobileExplorerVisible,
+ close: closeMobileExplorer,
+ expectClosed: expectMobileExplorerHidden,
+ },
+] as const;
+
+for (const panel of COMPACT_PANELS) {
+ test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-focus-panel-",
+ title: `Composer focus under ${panel.name}`,
+ });
+ const draft = "Line one\nLine two\nLine three";
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+
+ await test.step("an untouched editor stays untouched", async () => {
+ await expectComposerNotFocused(page);
+ await panel.open(page);
+ await panel.expectOpen(page);
+ await panel.close(page);
+ await panel.expectClosed(page);
+ await expectComposerNotFocused(page);
+ });
+
+ await test.step("an inline editor blurred under the panel stays blurred", async () => {
+ await composerLocator(page).fill(draft);
+ await composerLocator(page).click();
+ await expectComposerFocused(page);
+ await panel.open(page);
+ await panel.expectOpen(page);
+ await blurComposerInput(page);
+ await panel.close(page);
+ await panel.expectClosed(page);
+ await expectComposerNotFocused(page);
+ await expectComposerDraft(page, draft);
+ });
+
+ await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
+ await composerLocator(page).click();
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenControls(page);
+ await expectComposerFocused(page);
+ await panel.open(page);
+ await panel.expectOpen(page);
+ await blurComposerInput(page);
+ await panel.close(page);
+ await panel.expectClosed(page);
+ await expectComposerNotFocused(page);
+ await expectComposerFullscreenControls(page);
+ await expectComposerDraft(page, draft);
+ await exitComposerFullscreen(page);
+ await expectWorkspaceHeaderVisible(page);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+ });
+}
diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts
index 024446967f..8e1a527f0f 100644
--- a/packages/app/e2e/browser/composer-whitespace.spec.ts
+++ b/packages/app/e2e/browser/composer-whitespace.spec.ts
@@ -1,3 +1,5 @@
+import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
+import { seedWorkspace } from "../support/helpers/seed-client";
import { expect, test, type Page } from "../support/fixtures";
import {
expectNearBottom,
@@ -5,8 +7,38 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
-import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
+import {
+ attachComposerTestImage,
+ pasteComposerTestImage,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
+ resizeComposerViewport,
+ selectComposerDraftRange,
+ expectComposerDraftSelection,
+ expectComposerFullscreenControls,
+ sendComposerWithButton,
+ queueComposerWithButton,
+ configureComposerQueueSend,
+ activateRetainedComposerTab,
+ switchComposerTabWithKeyboard,
+ expectWorkspaceHeaderHidden,
+ expectWorkspaceHeaderVisible,
+ recordVisibleComposerGeometry,
+ expectNoCollapsedVisibleComposer,
+ expectAttachmentPill,
+ expectQueuedMessageButton,
+ startRunningMockAgent,
+ composerLocator,
+ expectComposerVisible,
+ expectComposerFullscreenFitsVisibleViewport,
+} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import {
+ closeMobileAgentSidebar,
+ expectMobileAgentSidebarHidden,
+ expectMobileAgentSidebarVisible,
+ swipeOpenMobileAgentSidebar,
+} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
@@ -133,3 +165,471 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
await agent.cleanup();
}
});
+
+/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
+async function pausePageClock(page: Page): Promise {
+ const now = Date.now();
+ await page.clock.install({ time: now });
+ // The installed clock advances between protocol calls; pause at a future time.
+ await page.clock.pauseAt(now + 60_000);
+}
+
+/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
+async function expectFullscreenErrorAboveSend(page: Page): Promise {
+ const frame = page.getByTestId("composer-fullscreen");
+ const alertBox = (await frame.getByRole("alert").boundingBox())!;
+ const sendBox = (await page
+ .getByRole("button", { name: "Send message", exact: true })
+ .boundingBox())!;
+ const editorBox = (await composerLocator(page).boundingBox())!;
+ expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
+ expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
+}
+
+async function writeCompactDraft(page: Page, text: string): Promise {
+ await composerLocator(page).fill(text);
+ await expect(composerLocator(page)).toHaveValue(text);
+}
+
+test("compact long drafts stop growing before the header", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-cap-",
+ title: "Composer cap",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "A long draft line\n".repeat(40));
+ await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-",
+ title: "Fullscreen composer",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
+ await attachComposerTestImage(page);
+ await expectAttachmentPill(page, "composer-image-attachment-pill");
+ await writeCompactDraft(page, "First line\nSecond line");
+ await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
+ await writeCompactDraft(
+ page,
+ "A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
+ 4,
+ ),
+ );
+ await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
+ });
+ await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
+ await selectComposerDraftRange(page, 4, 17);
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenControls(page);
+ await expectWorkspaceHeaderHidden(page);
+ await expectComposerDraftSelection(page, 4, 17);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
+ await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
+ await selectComposerDraftRange(page, 3, 12);
+ });
+ await test.step("exit restores the inline composer with the fullscreen edits", async () => {
+ await exitComposerFullscreen(page);
+ await expectWorkspaceHeaderVisible(page);
+ await expect(composerLocator(page)).toHaveValue(
+ "Edited fullscreen draft\nSecond line\nThird line",
+ );
+ await expectComposerDraftSelection(page, 3, 12);
+ await expectAttachmentPill(page, "composer-image-attachment-pill");
+ await expect(composerLocator(page)).toBeFocused();
+ });
+ await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
+ await sendComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
+ await writeCompactDraft(page, "Typed after the fullscreen cycle");
+ await sendComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ });
+ await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ await enterComposerFullscreen(page);
+ await writeCompactDraft(page, "Short draft");
+ await expectComposerFullscreenControls(page);
+ await selectComposerDraftRange(page, 0, 5);
+ await resizeComposerViewport(page, "landscape");
+ await expectComposerFullscreenControls(page);
+ await expectComposerDraftSelection(page, 0, 5);
+ await expect(composerLocator(page)).toHaveValue("Short draft");
+ await expect(composerLocator(page)).toBeFocused();
+ await resizeComposerViewport(page, "portrait");
+ await sendComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ await expectWorkspaceHeaderVisible(page);
+ await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
+ });
+ await test.step("typing and sending keep working after leaving fullscreen again", async () => {
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ await enterComposerFullscreen(page);
+ await writeCompactDraft(page, "Typed inside fullscreen");
+ await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
+ await exitComposerFullscreen(page);
+ await expectWorkspaceHeaderVisible(page);
+ await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
+ await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
+ await sendComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ await writeCompactDraft(page, "One more inline message");
+ await sendComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ });
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-remount-caret-",
+ title: "Remount caret",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "Short draft");
+ await selectComposerDraftRange(page, 5, 5);
+ // The draft owner publishes web edits after the next paint. A rotation inside that
+ // window remounts the editor before the edit has reached the draft store; the paused
+ // clock holds the page there instead of racing the frame.
+ await pausePageClock(page);
+ await composerLocator(page).press("X");
+ await resizeComposerViewport(page, "landscape");
+ await page.clock.resume();
+ await expect(composerLocator(page)).toHaveValue("ShortX draft");
+ await expectComposerDraftSelection(page, 6, 6);
+ await expect(composerLocator(page)).toBeFocused();
+ await composerLocator(page).press("Y");
+ await expect(composerLocator(page)).toHaveValue("ShortXY draft");
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("fullscreen submit rejection retains the editable draft and supports retry", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-retry-",
+ title: "Fullscreen retry",
+ featureValues: { mockPromptRejections: 1 },
+ });
+ const draft = "First line\nSecond line\nThird line";
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await sendComposerWithButton(page);
+ await expectWorkspaceHeaderVisible(page);
+ await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
+ await enterComposerFullscreen(page);
+ await expectFullscreenErrorAboveSend(page);
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await expect(composerLocator(page)).toBeEditable();
+ await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
+ await sendComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ await expectWorkspaceHeaderVisible(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+async function blurComposer(page: Page): Promise {
+ await composerLocator(page).blur();
+ await expect(composerLocator(page)).not.toBeFocused();
+}
+
+/** Mid-band at the given height and near the editor's bottom edge. */
+async function reservedBandPoints(
+ page: Page,
+ bandX: number,
+ topY: number,
+): Promise> {
+ const editor = (await composerLocator(page).boundingBox())!;
+ return [
+ { x: bandX, y: topY },
+ { x: bandX, y: editor.y + editor.height - 6 },
+ ];
+}
+
+test("the reserved band beside the inline editor focuses it", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-band-focus-",
+ title: "Composer band focus",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
+ const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
+ const longDraft = "Band first line\nBand second line\nBand third line";
+ await writeCompactDraft(page, longDraft);
+ await expect(fullscreenButton).toBeVisible();
+ const button = (await fullscreenButton.boundingBox())!;
+ const bandX = button.x + button.width / 2;
+
+ await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
+ for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
+ await blurComposer(page);
+ await page.mouse.click(point.x, point.y);
+ await expect(composerLocator(page)).toBeFocused();
+ await expect(composerLocator(page)).toHaveValue(longDraft);
+ await expect(exitButton).toHaveCount(0);
+ }
+ });
+
+ await test.step("the button itself still toggles fullscreen", async () => {
+ await fullscreenButton.click();
+ await expectComposerFullscreenControls(page);
+ await exitComposerFullscreen(page);
+ await expect(exitButton).toHaveCount(0);
+ await expect(composerLocator(page)).toHaveValue(longDraft);
+ });
+
+ await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
+ await writeCompactDraft(page, "Short");
+ await expect(fullscreenButton).toHaveCount(0);
+ const editor = (await composerLocator(page).boundingBox())!;
+ for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
+ await blurComposer(page);
+ await page.mouse.click(point.x, point.y);
+ await expect(composerLocator(page)).toBeFocused();
+ await expect(composerLocator(page)).toHaveValue("Short");
+ await expect(exitButton).toHaveCount(0);
+ }
+ });
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
+ page,
+}) => {
+ await configureComposerQueueSend(page);
+ await resizeComposerViewport(page, "portrait");
+ const agent = await startRunningMockAgent(page, {
+ prefix: "composer-fullscreen-queue-",
+ model: "one-minute-stream",
+ prompt: "Stay running for fullscreen queue test.",
+ });
+ try {
+ await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenControls(page);
+ await queueComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ await expectWorkspaceHeaderVisible(page);
+ await expectQueuedMessageButton(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-paste-remount-",
+ title: "Composer paste",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
+ await enterComposerFullscreen(page);
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await exitComposerFullscreen(page);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
+ page,
+}) => {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
+ try {
+ const first = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "First retained chat",
+ });
+ const second = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Second retained chat",
+ });
+ await openWorkspaceWithAgents(page, [first, second]);
+ await resizeComposerViewport(page, "portrait");
+ const draft = "Live first line\nLive second line\nLive third line";
+ await writeCompactDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await expectWorkspaceHeaderHidden(page);
+ await selectComposerDraftRange(page, 4, 17);
+ // Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
+ // a desktop-width window still has the tab shortcut.
+ await resizeComposerViewport(page, "landscape");
+ await switchComposerTabWithKeyboard(page, "previous");
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
+ // The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
+ await composerLocator(page).press("Escape");
+ await switchComposerTabWithKeyboard(page, "next");
+ await expectComposerFullscreenControls(page);
+ await switchComposerTabWithKeyboard(page, "previous");
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
+ await resizeComposerViewport(page, "portrait");
+ await expectWorkspaceHeaderVisible(page);
+ await writeCompactDraft(page, "Other tab draft");
+ await shrinkVisibleViewport(page);
+ await activateRetainedComposerTab(page, second.title);
+ await expectComposerFullscreenControls(page);
+ await expectWorkspaceHeaderHidden(page);
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ await expectComposerDraftSelection(page, 4, 17);
+ await expect(composerLocator(page)).toBeFocused();
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await exitComposerFullscreen(page);
+ await expectAttachmentPill(page, "composer-image-attachment-pill");
+ } finally {
+ await workspace.cleanup();
+ }
+});
+
+test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-sidebar-",
+ title: "Fullscreen under sidebar",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ const draft = "Line one\nLine two\nLine three";
+ await writeCompactDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await expectWorkspaceHeaderHidden(page);
+ // Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
+ await swipeOpenMobileAgentSidebar(page);
+ await expectMobileAgentSidebarVisible(page);
+ // The list covers the pane, so the editor's exit action is not armed.
+ await page.keyboard.press("Escape");
+ await closeMobileAgentSidebar(page);
+ await expectMobileAgentSidebarHidden(page);
+ await expectComposerFullscreenControls(page);
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await exitComposerFullscreen(page);
+ await expectWorkspaceHeaderVisible(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("retained inline composers keep usable capacity through hidden tab geometry", async ({
+ page,
+}) => {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
+ try {
+ const first = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "First capacity chat",
+ });
+ const second = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Second capacity chat",
+ });
+ await openWorkspaceWithAgents(page, [first, second]);
+ await resizeComposerViewport(page, "portrait");
+ const draft = "Retained long line\n".repeat(40);
+ await writeCompactDraft(page, draft);
+ await recordVisibleComposerGeometry(page);
+ await activateRetainedComposerTab(page, first.title);
+ await writeCompactDraft(page, "Other visible draft");
+ await activateRetainedComposerTab(page, second.title);
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
+ await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
+ await expectNoCollapsedVisibleComposer(page);
+ } finally {
+ await workspace.cleanup();
+ }
+});
+
+async function shrinkVisibleViewport(page: Page): Promise {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
+ await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
+ await cdp.detach();
+}
+
+async function panVisibleViewport(page: Page): Promise {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchStart",
+ touchPoints: [{ x: 4, y: 300 }],
+ });
+ for (let y = 290; y >= 100; y -= 10) {
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchMove",
+ touchPoints: [{ x: 4, y }],
+ });
+ }
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
+ await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
+ await cdp.detach();
+}
+
+test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-visible-viewport-",
+ title: "Visible viewport composer",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ await shrinkVisibleViewport(page);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ await panVisibleViewport(page);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts
index 39e73dbded..7658addf72 100644
--- a/packages/app/e2e/browser/creation-idempotency.spec.ts
+++ b/packages/app/e2e/browser/creation-idempotency.spec.ts
@@ -128,3 +128,46 @@ for (const scenario of [
await creation.expectAgentCount(1);
});
}
+
+for (const entry of [
+ {
+ name: "agent draft",
+ open: (creation: Awaited>) =>
+ creation.openAgentDraft(),
+ button: "Send message",
+ settled: async (_creation: Awaited>) => {},
+ },
+ {
+ name: "new workspace",
+ open: (creation: Awaited>) =>
+ creation.openWorkspaceForm("local"),
+ button: "Create",
+ settled: (creation: Awaited>) =>
+ creation.expectOneCreatedWorkspace(),
+ },
+]) {
+ test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
+ creation,
+ promptRejection,
+ page,
+ }, testInfo) => {
+ void promptRejection;
+ const prompt =
+ "Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
+ await entry.open(creation);
+ await creation.submitPrompt(prompt, entry.button);
+ await entry.settled(creation);
+ await creation.expectCreatedAgentError();
+ await creation.expectPromptBeforeError(prompt);
+ await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
+ await creation.reloadAgent();
+ await creation.expectPromptBeforeError(prompt);
+ await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
+ await creation.expectPromptVisible(
+ "emit 1 coalesced agent stream updates for a corrected prompt.",
+ );
+ await creation.expectAssistantReply();
+ await creation.expectAgentCount(1);
+ await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
+ });
+}
diff --git a/packages/app/e2e/browser/creation-old-daemon.spec.ts b/packages/app/e2e/browser/creation-old-daemon.spec.ts
index ff28d513ef..4b418b304a 100644
--- a/packages/app/e2e/browser/creation-old-daemon.spec.ts
+++ b/packages/app/e2e/browser/creation-old-daemon.spec.ts
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
);
expect(workspaces).toHaveLength(1);
gate.release();
+ await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
+ await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers();
+ let created = Promise.withResolvers();
+ let createRequestId: string | undefined;
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
- if (holding && readers.client(frame)?.type === "create_agent_request") {
+ const message = readers.client(frame);
+ if (message?.type === "create_agent_request") createRequestId = message.requestId;
+ if (holding && message?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
- server.onMessage((frame) => browser.send(frame));
+ server.onMessage((frame) => {
+ const message = readers.server(frame);
+ if (
+ message?.type === "status" &&
+ message.payload.status === "agent_created" &&
+ message.payload.requestId === createRequestId
+ ) {
+ created.resolve();
+ }
+ browser.send(frame);
+ });
});
return {
waitForRequest: () => arrived.promise,
+ async waitForCreated() {
+ // Agent-list membership includes initialization, before the initial prompt starts.
+ await created.promise;
+ created = Promise.withResolvers();
+ },
release() {
const send = release;
release = () => {};
diff --git a/packages/app/e2e/browser/custom-model-removal.spec.ts b/packages/app/e2e/browser/custom-model-removal.spec.ts
new file mode 100644
index 0000000000..dc6fd84616
--- /dev/null
+++ b/packages/app/e2e/browser/custom-model-removal.spec.ts
@@ -0,0 +1,29 @@
+import { test } from "../support/fixtures";
+import {
+ addCustomModel,
+ expectCustomModelRetained,
+ openCustomModels,
+ removeCustomModel,
+} from "../support/helpers/custom-models";
+
+const longModelId = "custom-model-with-a-long-name-".repeat(6);
+const shortModelId = "custom-short";
+
+for (const width of [1200, 390]) {
+ test(`removes a long custom model at ${width}px without removing another model`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1200, height: 1000 });
+ await openCustomModels(page);
+ await addCustomModel(page, shortModelId);
+ await addCustomModel(page, longModelId);
+
+ await test.step("remove the long model at the target width", async () => {
+ await page.setViewportSize({ width, height: 1000 });
+ await removeCustomModel(page, longModelId);
+ await expectCustomModelRetained(page, shortModelId);
+ });
+
+ await removeCustomModel(page, shortModelId);
+ });
+}
diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts
index 43e1e03863..7726c581f3 100644
--- a/packages/app/e2e/browser/file-editing.spec.ts
+++ b/packages/app/e2e/browser/file-editing.spec.ts
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
+ expandFolder,
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise {
+ const image = page.getByRole("img", { name, exact: true });
+ await expect(image).toBeVisible();
+ await expect
+ .poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
+ .toBeGreaterThan(0);
+}
+
function watchRequestsTo(page: Page, origin: string): string[] {
const requests: string[] = [];
page.on("request", (request) => {
@@ -96,6 +105,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
+ test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "encoded-markdown-link-",
+ title: "Non-ASCII file link",
+ initialPrompt: "Show the report link",
+ featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
+ });
+ try {
+ await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
+ await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
+ await openAgentRoute(page, session);
+ await openAssistantMarkdownLink(page, "报告");
+ await expectFileTabOpen(page, "docs/reports/开户赠金.md");
+ await expect(
+ page
+ .getByTestId("workspace-file-pane")
+ .filter({ visible: true })
+ .getByText("Report opened", { exact: true }),
+ ).toBeVisible();
+ await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
+ } finally {
+ await session.cleanup();
+ }
+ });
+
+ for (const order of ["raw-first", "href-first"] as const) {
+ test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "percent-link-cache-",
+ title: "Distinct file links",
+ initialPrompt: "Show both filenames",
+ featureValues: {
+ mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
+ },
+ });
+ try {
+ await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
+ await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
+ await openAgentRoute(page, session);
+ const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
+ for (const link of links) {
+ await returnToAssistant(page, session.agentId);
+ if (link === "raw") {
+ await openAssistantInlinePath(page, "literal%20.md");
+ await expectFileTabOpen(page, "literal%20.md");
+ await expectPreviewText(page, "Literal filename");
+ } else {
+ await openAssistantMarkdownLink(page, "Decoded");
+ await expectFileTabOpen(page, "literal .md");
+ await expectPreviewText(page, "Decoded filename");
+ }
+ }
+ } finally {
+ await session.cleanup();
+ }
+ });
+ }
+
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -626,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
+ test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
+ test.setTimeout(90_000);
+ const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
+ await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
+ await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
+ await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
+ await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
+ await writeFile(
+ path.join(workspace.repoPath, "docs", "guide.md"),
+ [
+ "# Guide",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ await workspace.navigateTo();
+ await openFileExplorer(page);
+ await expandFolder(page, "docs");
+ await openFileFromExplorer(page, "guide.md");
+ await expectFileTabOpen(page, "docs/guide.md");
+
+ const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
+ await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
+ await expectMarkdownImageLoaded(page, "sibling");
+ await expectMarkdownImageLoaded(page, "hero");
+ await visibleFilePane.screenshot({
+ path: test.info().outputPath("markdown-relative-images.png"),
+ });
+ });
+
test("previews and refreshes an HTML plan while preserving source access", async ({
page,
withWorkspace,
@@ -757,3 +858,24 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
+
+async function openAssistantMarkdownLink(page: Page, name: string): Promise {
+ await page.getByRole("link", { name, exact: true }).first().click();
+}
+
+async function returnToAssistant(page: Page, agentId: string): Promise {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
+}
+
+async function openAssistantInlinePath(page: Page, token: string): Promise {
+ await page.getByText(token, { exact: true }).click();
+}
+
+async function expectPreviewText(page: Page, text: string): Promise {
+ await expect(
+ page
+ .getByTestId("workspace-file-pane")
+ .filter({ visible: true })
+ .getByText(text, { exact: true }),
+ ).toBeVisible();
+}
diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts
index bbc73b797e..faf7285a62 100644
--- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts
+++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
-import { openFileExplorer } from "../support/helpers/file-explorer";
+import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
-import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
+import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
+
+test("downloads a file over direct TCP with its original filename and bytes", async ({
+ page,
+}, testInfo) => {
+ const fileName = "rapport données.bin";
+ const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
+ await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
+ await gotoWorkspace(page, workspace.workspaceId);
+ await openFileExplorer(page);
+
+ const downloaded = await downloadFileFromExplorer(page, fileName);
+ const url = new URL(downloaded.url());
+ expect(url.protocol).toBe("http:");
+ expect(url.port).toBe(getE2EDaemonPort());
+ expect(url.pathname).toBe("/api/files/download");
+ expect(url.searchParams.get("token")).toBeTruthy();
+ expect(downloaded.suggestedFilename()).toBe(fileName);
+ const savedFile = testInfo.outputPath(fileName);
+ await downloaded.saveAs(savedFile);
+ expect(await downloaded.failure()).toBeNull();
+ expect(await readFile(savedFile)).toEqual(source);
+});
diff --git a/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts
new file mode 100644
index 0000000000..60e13b70a6
--- /dev/null
+++ b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts
@@ -0,0 +1,110 @@
+import path from "node:path";
+import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
+import { expect, test, type Page } from "../support/fixtures";
+import { gotoAppShell, openSettings } from "../support/helpers/app";
+import {
+ closeModelPicker,
+ drillIntoProvider,
+ openModelPicker,
+} from "../support/helpers/agent-profiles";
+import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer";
+import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
+import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
+import { getServerId } from "../support/helpers/server-id";
+import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings";
+
+// Use an existing repository linked to the signed-in user's Hoplite project.
+const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../..");
+
+test.use({
+ viewport: { width: 1440, height: 1000 },
+ e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } },
+});
+
+async function openHopliteSettings(page: Page): Promise {
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHost(page, getServerId());
+ await openSettingsHostSection(page, getServerId(), "providers");
+ await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click();
+}
+
+async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise {
+ const models = await client.listProviderModels("hoplite");
+ expect(models.error).toBeNull();
+ expect(models.models).toEqual([]);
+ const snapshot = await client.getProvidersSnapshot();
+ expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({
+ status: "ready",
+ models: [],
+ });
+ await expect(page.getByText("No models detected", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
+}
+
+async function refreshGlobalCatalog(page: Page): Promise {
+ await page.getByRole("button", { name: "Refresh", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({
+ timeout: 120_000,
+ });
+}
+
+async function openWorkspaceModels(page: Page, workspaceId: string): Promise {
+ await gotoWorkspace(page, workspaceId);
+ await clickNewChat(page);
+ await expectComposerVisible(page);
+ await expectComposerFocused(page);
+ await openModelPicker(page);
+ await page.getByTestId("sheet-header-back").click();
+ await drillIntoProvider(page, "hoplite");
+}
+
+test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({
+ page,
+ e2eWorkerClient,
+}, testInfo) => {
+ test.setTimeout(180_000);
+ const client = await connectDaemonClient({ clientIdPrefix: "hoplite-discovery" });
+ const created = await e2eWorkerClient.createWorkspace({
+ source: { kind: "directory", path: cwd },
+ title: "Hoplite discovery QA",
+ });
+ expect(created.error).toBeNull();
+ const workspace = created.workspace!;
+ try {
+ await test.step("global settings succeed with no models, including refresh", async () => {
+ await openHopliteSettings(page);
+ await expectEmptyGlobalCatalog(page, client);
+ await refreshGlobalCatalog(page);
+ await expectEmptyGlobalCatalog(page, client);
+ await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") });
+ });
+
+ await test.step("the linked workspace discovers real models", async () => {
+ const models = await client.listProviderModels("hoplite", { cwd });
+ expect(models.error).toBeNull();
+ expect(models.models!.length).toBeGreaterThan(0);
+ await testInfo.attach("workspace-models", {
+ body: JSON.stringify(models, null, 2),
+ contentType: "application/json",
+ });
+ await openWorkspaceModels(page, workspace.id);
+ await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") });
+ await closeModelPicker(page);
+ });
+
+ await test.step("workspace discovery does not populate the global catalog", async () => {
+ await openHopliteSettings(page);
+ await expectEmptyGlobalCatalog(page, client);
+ await refreshGlobalCatalog(page);
+ await expectEmptyGlobalCatalog(page, client);
+ const models = await client.listProviderModels("hoplite", { cwd });
+ expect(models.error).toBeNull();
+ expect(models.models!.length).toBeGreaterThan(0);
+ });
+ } finally {
+ await client.close();
+ await e2eWorkerClient.removeProject(workspace.projectId);
+ }
+});
diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts
index e7dbde3bd9..906efcfb42 100644
--- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts
@@ -1,7 +1,14 @@
+import {
+ composerLocator,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
+ resizeComposerViewport,
+} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
+ delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
}
});
});
+
+test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
+ page,
+}) => {
+ const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
+ const delay = await delayBrowserWorkspaceCreatedResponse(page);
+ try {
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ await openNewWorkspaceComposer(page, project);
+ await resizeComposerViewport(page, "portrait");
+ await fillNewWorkspaceDraft(page, DRAFT);
+ await enterComposerFullscreen(page);
+ await page.getByRole("button", { name: "Create", exact: true }).click();
+ await delay.waitForCreateRequest();
+
+ await test.step("submission returns inline with the original draft and spinner", async () => {
+ await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
+ await expect(composerLocator(page)).toHaveValue(DRAFT);
+ await expect(composerLocator(page)).not.toBeEditable();
+ await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
+ await expect(page.getByRole("progressbar")).toBeVisible();
+ });
+ await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
+ await enterComposerFullscreen(page);
+ await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
+ await expect(composerLocator(page)).toHaveValue(DRAFT);
+ await expect(composerLocator(page)).not.toBeEditable();
+ await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
+ await expect(page.getByRole("progressbar")).toBeVisible();
+ await exitComposerFullscreen(page);
+ });
+ delay.release();
+ await page.waitForURL((url) => url.pathname.includes("/workspace/"));
+ await expect(composerLocator(page)).toHaveValue("");
+ await expect(composerLocator(page)).toBeEditable();
+ expect(delay.agentRequests).toHaveLength(1);
+ } finally {
+ delay.release();
+ await project.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts
index d4444d6f22..22b2b8a6c5 100644
--- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts
@@ -6,6 +6,15 @@ import {
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import {
+ composerLocator,
+ expectComposerVisible,
+ fillComposerDraft,
+ enterComposerFullscreen,
+ resizeComposerViewport,
+ expectComposerFullscreenControls,
+} from "../support/helpers/composer";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
@@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => {
}
});
});
+
+async function startDictationWithShortcut(page: Page): Promise {
+ await composerLocator(page).press("Control+d");
+}
+
+test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "fullscreen-dictation-",
+ title: "Fullscreen dictation shortcut",
+ });
+ await installSyntheticMicrophone(page);
+ const harness = await installDictationFailureHarness(page);
+ const draft = "First line\nSecond line\nThird line";
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await fillComposerDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await resizeComposerViewport(page, "landscape");
+ await expectComposerFullscreenControls(page);
+ await startDictationWithShortcut(page);
+ await harness.waitForAudio();
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Insert transcription", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Insert transcription and send", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
+ await expect(composerLocator(page)).toBeEditable();
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await resizeComposerViewport(page, "portrait");
+ await enterComposerFullscreen(page);
+ await resizeComposerViewport(page, "landscape");
+ await composerLocator(page).press("Control+Shift+d");
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
+ await expect(
+ page.getByText("Realtime voice is disabled in this test.", { exact: true }),
+ ).toBeVisible();
+ await expect(composerLocator(page)).toHaveValue(draft);
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts
index 16b277b537..d393966ee8 100644
--- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts
@@ -1,7 +1,18 @@
-import { expect, test } from "../support/fixtures";
+import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
-import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
+import {
+ expectNewWorkspaceDraft,
+ fillNewWorkspaceDraft,
+ openNewWorkspaceComposer,
+} from "../support/helpers/new-workspace";
+import {
+ composerLocator,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
+ expectComposerFullscreenControls,
+ resizeComposerViewport,
+} from "../support/helpers/composer";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalLaunchSelected,
@@ -9,13 +20,12 @@ import {
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
- seedTerminalProfiles,
+ launchTrigger,
selectLaunchOption,
submitTerminalLaunch,
terminalLaunchSubmit,
terminalPromptInput,
type TerminalProfile,
- type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Someone who wants a terminal agent instead of a chat: they pick a profile
@@ -42,17 +52,18 @@ const BARE_PROFILE: TerminalProfile = {
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
+test.use({
+ e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
+});
+
test.describe("New workspace: launching a terminal", () => {
let workspace: SeededWorkspace;
- let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
- profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
});
test.afterEach(async () => {
- await profileSeed.restore();
await workspace?.cleanup();
});
@@ -97,4 +108,165 @@ test.describe("New workspace: launching a terminal", () => {
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
});
+
+ test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
+ page,
+ }) => {
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ await openNewWorkspaceComposer(page, {
+ projectKey: workspace.projectKey,
+ projectDisplayName: workspace.projectDisplayName,
+ });
+ await page.setViewportSize({ width: 390, height: 844 });
+ const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
+ const selectProfile = async (profile: TerminalProfile) => {
+ await launchTrigger(page).click();
+ const option = page.getByRole("menuitem").filter({ hasText: profile.name });
+ await option.click();
+ await expect(option).not.toBeVisible();
+ };
+ const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
+
+ await test.step("restore a multiline prompt after a read-only profile", async () => {
+ await selectProfile(PROMPT_PROFILE);
+ await fillTerminalPrompt(page, prompt);
+ await expect(fullscreen).toBeVisible();
+ await selectProfile(BARE_PROFILE);
+ await expect(terminalPromptInput(page)).toHaveCount(0);
+ await selectProfile(PROMPT_PROFILE);
+ await expect(terminalPromptInput(page)).toHaveValue(prompt);
+ await expect(fullscreen).toBeVisible();
+ });
+
+ await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
+ await fillTerminalPrompt(page, "Short draft");
+ await expect(fullscreen).toHaveCount(0);
+ await selectProfile(BARE_PROFILE);
+ await selectProfile(PROMPT_PROFILE);
+ await expect(terminalPromptInput(page)).toHaveValue("Short draft");
+ await expect(fullscreen).toHaveCount(0);
+ await terminalPromptInput(page).press("End");
+ await terminalPromptInput(page).press("Shift+Enter");
+ await terminalPromptInput(page).pressSequentially("Second");
+ await terminalPromptInput(page).press("Shift+Enter");
+ await terminalPromptInput(page).pressSequentially("Third");
+ await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
+ await expect(fullscreen).toBeVisible();
+ });
+ });
+});
+
+async function expectNewWorkspaceHeaderHidden(page: Page): Promise {
+ await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
+}
+
+async function expectNewWorkspaceHeaderVisible(page: Page): Promise {
+ await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
+}
+
+/**
+ * Fullscreen fills the content area top to bottom and out to the right edge (a pinned
+ * sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
+ */
+async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise {
+ const frame = page.getByTestId("composer-fullscreen");
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
+ await expect(frame.getByRole("button")).toHaveCount(2);
+ await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
+ await expectNewWorkspaceHeaderHidden(page);
+ const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
+ await expect
+ .poll(() =>
+ frame.evaluate(
+ (element, point) => {
+ const viewport = window.visualViewport!;
+ const box = element.getBoundingClientRect();
+ const covering = document.elementFromPoint(point.x, point.y);
+ return {
+ fillsHeight:
+ Math.abs(box.top - viewport.offsetTop) < 1 &&
+ Math.abs(box.height - viewport.height) < 1,
+ reachesRightEdge:
+ Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
+ box.left >= viewport.offsetLeft,
+ coversForm: element.contains(covering),
+ };
+ },
+ { x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
+ ),
+ )
+ .toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
+}
+
+test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
+ let workspace: SeededWorkspace;
+
+ test.beforeEach(async () => {
+ workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
+ });
+
+ test.afterEach(async () => {
+ await workspace?.cleanup();
+ });
+
+ test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ await openNewWorkspaceComposer(page, {
+ projectKey: workspace.projectKey,
+ projectDisplayName: workspace.projectDisplayName,
+ });
+ const projectPicker = page.getByRole("button", { name: "Workspace project" });
+
+ await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
+ await resizeComposerViewport(page, "portrait");
+ const draft = "Chat first line\nChat second line\nChat third line";
+ await fillNewWorkspaceDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenControls(page);
+ await expectNewWorkspaceHeaderHidden(page);
+ await resizeComposerViewport(page, "landscape");
+ await expectComposerFullscreenControls(page);
+ await expectNewWorkspaceFullscreen(page, "Create");
+ // The desktop form's own import button is dropped from the fullscreen slot.
+ await expect(
+ page.getByTestId("new-workspace-import-session").filter({ visible: true }),
+ ).toHaveCount(0);
+ await composerLocator(page).press("End");
+ await composerLocator(page).pressSequentially(" wide");
+ await exitComposerFullscreen(page);
+ await expectNewWorkspaceHeaderVisible(page);
+ await expect(projectPicker).toBeVisible();
+ await expect(
+ page.getByTestId("new-workspace-import-session").filter({ visible: true }),
+ ).toBeVisible();
+ await expectNewWorkspaceDraft(page, `${draft} wide`);
+ });
+
+ // Terminal mode and its prompt are screen state, and the shell remounts the screen
+ // at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
+ // layout only.
+ await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
+ await resizeComposerViewport(page, "portrait");
+ // The compact launch control opens a sheet, not the desktop menu.
+ await launchTrigger(page).click();
+ const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
+ await option.click();
+ await expect(option).not.toBeVisible();
+ await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
+ const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
+ await fillTerminalPrompt(page, prompt);
+ await enterComposerFullscreen(page);
+ await expect(terminalPromptInput(page)).toBeVisible();
+ await expectNewWorkspaceFullscreen(page, "Launch");
+ await exitComposerFullscreen(page);
+ await expectNewWorkspaceHeaderVisible(page);
+ await expect(projectPicker).toBeVisible();
+ await expect(terminalPromptInput(page)).toHaveValue(prompt);
+ });
+ });
});
diff --git a/packages/app/e2e/browser/pair-device-relay.spec.ts b/packages/app/e2e/browser/pair-device-relay.spec.ts
index 8c4e306a1e..dbd3814d38 100644
--- a/packages/app/e2e/browser/pair-device-relay.spec.ts
+++ b/packages/app/e2e/browser/pair-device-relay.spec.ts
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
+ expectPairingOffer,
+ expectCompletePairingQr,
expectRelayConsent,
openPairDeviceModal,
preparePairingHost,
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
await daemon.close();
}
});
+
+test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
+ const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
+ mutableRelay: { enabled: true },
+ });
+ try {
+ await preparePairingHost(page, daemon);
+ await page.setViewportSize({ width: 320, height: 568 });
+ await openPairDeviceModal(page);
+ await expectPairingOffer(page);
+
+ await expectCompletePairingQr(page);
+ await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
+ } finally {
+ await daemon.close();
+ }
+});
diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts
index 7e32f61350..84b4cc63a2 100644
--- a/packages/app/e2e/browser/plugin-management.spec.ts
+++ b/packages/app/e2e/browser/plugin-management.spec.ts
@@ -1,3 +1,4 @@
+import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
- "Plugin source is neither an existing directory nor a Git URL",
+ `Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
+
+test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
+ const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
+ await installPluginWithUntrustedDescription(pluginEnvironment, description);
+ await gotoAppShell(page);
+ await openPluginSettings(page);
+ const text = page.getByText(description, { exact: true });
+ await expect(text).toBeVisible();
+ // This field must remain a text node, with no Markdown links, images, or HTML children.
+ expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
+});
+
+async function installPluginWithUntrustedDescription(
+ environment: PluginEnvironment,
+ description: string,
+) {
+ const directory = await createDirectoryPlugin(
+ environment.directory,
+ "plain-text-plugin",
+ description,
+ "Plain text plugin",
+ );
+ await environment.client.installPluginSource({ source: directory });
+}
diff --git a/packages/app/e2e/browser/plugin-popover.spec.ts b/packages/app/e2e/browser/plugin-popover.spec.ts
new file mode 100644
index 0000000000..8ce3b35513
--- /dev/null
+++ b/packages/app/e2e/browser/plugin-popover.spec.ts
@@ -0,0 +1,47 @@
+import { expect, test, type Page } from "../support/fixtures";
+import { composerLocator, submitMessageWithButton } from "../support/helpers/composer";
+import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
+import { withPopoverTimelinePlugin } from "../support/helpers/plugin-timeline";
+
+async function openPreviewWhileTyping(page: Page): Promise {
+ await composerLocator(page).focus();
+ await page.getByRole("button", { name: "Hover plugin preview", exact: true }).hover();
+ await expect(page.getByRole("dialog", { name: "Plugin preview" })).toBeVisible();
+ await expect(composerLocator(page)).toBeFocused();
+}
+
+async function dismissPreview(page: Page): Promise {
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog", { name: "Plugin preview" })).not.toBeVisible();
+}
+
+test("Escape dismisses a plugin preview before interrupting the agent", async ({ page }) => {
+ test.setTimeout(120_000);
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "plugin-popover-",
+ title: "Plugin preview keyboard regression",
+ model: "e2e-fast-stream",
+ });
+ try {
+ await withPopoverTimelinePlugin(async () => {
+ await openAgentRoute(page, agent);
+ await submitMessageWithButton(page, "Show the plugin preview.");
+ await agent.client.waitForFinish(agent.agentId, 30_000);
+ await expect(page.getByRole("button", { name: "Hover plugin preview" })).toBeVisible();
+ await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
+ const stop = page.getByRole("button", { name: "Stop agent", exact: true });
+ await expect(stop).toBeVisible();
+
+ await openPreviewWhileTyping(page);
+ await dismissPreview(page);
+ await expect(stop).toBeVisible();
+ await expect(composerLocator(page)).toBeFocused();
+
+ await page.keyboard.press("Escape");
+ await expect(stop).not.toBeVisible();
+ await agent.client.waitForFinish(agent.agentId, 15_000);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/project-path-spaces.spec.ts b/packages/app/e2e/browser/project-path-spaces.spec.ts
new file mode 100644
index 0000000000..3b3478f5bb
--- /dev/null
+++ b/packages/app/e2e/browser/project-path-spaces.spec.ts
@@ -0,0 +1,64 @@
+import { execFileSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { expect, test as base } from "../support/fixtures";
+import { gotoWorkspace } from "../support/helpers/launcher";
+import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
+
+const test = base.extend<{ projectWithTrailingSpace: string }>({
+ projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
+ const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
+ try {
+ const repoPath = path.join(root, "repo ");
+ execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
+ execFileSync(
+ "git",
+ [
+ "-c",
+ "user.name=Paseo Test",
+ "-c",
+ "user.email=test@getpaseo.local",
+ "-c",
+ "commit.gpgsign=false",
+ "commit",
+ "--allow-empty",
+ "-m",
+ "initial",
+ ],
+ { cwd: repoPath, stdio: "pipe" },
+ );
+ await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
+ const added = await client.addProject(repoPath);
+ expect(added.error).toBeNull();
+ const projectId = added.project!.projectId;
+ try {
+ const created = await client.createWorkspace({
+ source: { kind: "directory", path: repoPath, projectId },
+ });
+ expect(created.error).toBeNull();
+ await provide(created.workspace!.id);
+ } finally {
+ await client.removeProject(projectId);
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ },
+});
+
+test.skip(
+ process.platform === "win32",
+ "Windows does not preserve trailing spaces in folder names",
+);
+
+test("Changes shows files in a project whose folder ends in a space", async ({
+ page,
+ projectWithTrailingSpace,
+}, testInfo) => {
+ await gotoWorkspace(page, projectWithTrailingSpace);
+ await openChangesTreePanel(page);
+ await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath("changes.png") });
+});
diff --git a/packages/app/e2e/browser/provider-removal.spec.ts b/packages/app/e2e/browser/provider-removal.spec.ts
index 7e17a1fbe0..b72fb868d6 100644
--- a/packages/app/e2e/browser/provider-removal.spec.ts
+++ b/packages/app/e2e/browser/provider-removal.spec.ts
@@ -10,16 +10,29 @@ import {
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
+import { copyPluginExample } from "../support/helpers/plugin-fixture";
const CUSTOM_PROVIDER = {
id: "junie",
name: "Junie",
} as const;
+const PLUGIN_PROVIDER = {
+ id: "direct-example",
+ name: "Direct provider example",
+ pluginId: "provider-direct-example",
+} as const;
+
interface ProviderRemovalDaemonClient {
connect(): Promise;
close(): Promise;
- patchDaemonConfig(config: { removeProviders?: string[] }): Promise;
+ getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
+ patchDaemonConfig(config: {
+ removeProviders?: string[];
+ pluginsEnabled?: boolean;
+ }): Promise;
+ installDirectoryPlugin(path: string): Promise;
+ removePlugin(pluginId: string): Promise;
getProvidersSnapshot(): Promise<{
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
}>;
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
await client.close().catch(() => undefined);
}
});
+
+ test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
+ test.setTimeout(120_000);
+ const client = await connectDaemonClient({
+ clientIdPrefix: "provider-removal-e2e",
+ });
+
+ const plugin = await copyPluginExample("provider-direct");
+ try {
+ const previous = await client.getDaemonConfig();
+ try {
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(plugin.directory);
+
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHost(page, getServerId());
+ await openSettingsHostSection(page, getServerId(), "providers");
+
+ const pluginProviderRow = page.getByRole("button", {
+ name: `${PLUGIN_PROVIDER.name} provider details`,
+ exact: true,
+ });
+ await expect(pluginProviderRow).toBeVisible();
+ await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
+
+ // The provider survives a removeProviders patch pointed at it: the
+ // plugin, not config.json, owns the definition.
+ await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
+ await expect(pluginProviderRow).toBeVisible();
+ } finally {
+ await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
+ await client
+ .patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
+ .catch(() => undefined);
+ }
+ } finally {
+ await client.close().catch(() => undefined);
+ await plugin.cleanup();
+ }
+ });
});
diff --git a/packages/app/e2e/browser/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts
index b014b2d1cd..1d75ae2839 100644
--- a/packages/app/e2e/browser/question-prompt-pagination.spec.ts
+++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts
@@ -7,6 +7,7 @@ import {
expectQuestionDismissEnabled,
expectQuestionHidden,
expectQuestionNavigationEnabled,
+ expectQuestionOptionPreview,
expectQuestionOptionSelected,
expectQuestionPrimaryActionDisabled,
expectQuestionPrimaryActionEnabled,
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
+ await expectQuestionOptionPreview(page, {
+ option: "Behind feature flag",
+ preview: 'flags.enabled("new-surface")',
+ });
await chooseQuestionOption(page, "Behind feature flag");
await expectCurrentQuestion(page, {
index: 3,
diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts
index 2d038abbdb..4c0162e8d8 100644
--- a/packages/app/e2e/browser/sidebar-help.spec.ts
+++ b/packages/app/e2e/browser/sidebar-help.spec.ts
@@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise {
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
}
+const MIN_TARGET_SIZE = 44;
+
async function expectFingerSizedFooterTargets(page: Page): Promise {
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
await expect(buttons).toHaveCount(5);
for (const button of await buttons.all()) {
const bounds = await button.boundingBox();
- expect(bounds?.width).toBeGreaterThanOrEqual(44);
- expect(bounds?.height).toBeGreaterThanOrEqual(44);
+ // Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
+ expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
+ expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
const glyphWidth = await button.locator("svg").first().getAttribute("width");
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
diff --git a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts
index 21ad51a6bb..6ed14d159a 100644
--- a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts
+++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
+import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
}
});
});
+
+async function toggleBackgroundWorkspaces(page: Page): Promise {
+ await page.getByLabel("Display preferences", { exact: true }).click();
+ await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
+ await page.keyboard.press("Escape");
+}
+
+async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise {
+ // Rows have no single accessible role: their nested title and actions share this container.
+ await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
+}
+
+async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise {
+ await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
+}
+
+async function reconnectApp(page: Page): Promise {
+ await page.reload();
+ await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
+}
+
+async function expectWorkerOpen(page: Page): Promise {
+ await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
+ timeout: 30_000,
+ });
+ await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
+}
+
+test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
+ page,
+}, testInfo) => {
+ const workspace = await seedWorkspace({
+ repoPrefix: "sidebar-background-",
+ title: "Background review",
+ background: true,
+ });
+ try {
+ const worker = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Background reviewer",
+ initialPrompt: "Background review prompt",
+ model: "e2e-fast-stream",
+ modeId: "load-test",
+ });
+ await test.step("Default sidebar hides background work", async () => {
+ await gotoAppShell(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ });
+ await test.step("Show background exposes the fully replicated workspace", async () => {
+ await toggleBackgroundWorkspaces(page);
+ await expectWorkspaceInSidebar(page, workspace.workspaceId);
+ await reconnectApp(page);
+ await expectWorkspaceInSidebar(page, workspace.workspaceId);
+ });
+ await test.step("Direct agent navigation survives hiding and reconnect", async () => {
+ await toggleBackgroundWorkspaces(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
+ await expectWorkerOpen(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ await reconnectApp(page);
+ await expectWorkerOpen(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts
similarity index 86%
rename from packages/app/e2e/browser/usage-screen.spec.ts
rename to packages/app/e2e/browser/usage-modal.spec.ts
index 1dfe89ae19..a923971d09 100644
--- a/packages/app/e2e/browser/usage-screen.spec.ts
+++ b/packages/app/e2e/browser/usage-modal.spec.ts
@@ -18,13 +18,15 @@ import {
installLoginUsage,
installCodexWindowUsage,
expectCodexReportedWindows,
- openUsage,
refreshLoginUsage,
hoverUsageWindow,
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
expectPinnedUsage,
+ openCompactSidebar,
+ openUsageFromIcon,
+ openUsageFromItem,
pinRow,
openUsageOptions,
closeUsageOptions,
@@ -98,7 +100,41 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
-test.describe("usage screen", () => {
+function installReversedUsageRefresh(page: Page) {
+ const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
+ const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
+ return installUsageReportsFixture(page, {
+ lists: [(request) => (request.forceRefresh ? refreshed : initial)],
+ });
+}
+
+async function openUsageSummaryAtWidth(page: Page, width: number) {
+ await page.setViewportSize({ width, height: 900 });
+ await gotoAppShell(page);
+ if (width === 390) await openCompactSidebar(page);
+ await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
+ await openUsageFromItem(page);
+}
+
+async function expectUsageProviderOrder(page: Page, summary: string[]) {
+ const group = page.getByTestId(`usage-host-${getServerId()}`);
+ await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
+ "opencode plan",
+ "openai plan",
+ ]);
+ await expectPinnedUsage(page, summary);
+}
+
+async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
+ const requestCount = usage.listRequests().length;
+ await refreshAllUsage(page);
+ await usage.waitForListRequests(requestCount + 1);
+ const group = page.getByTestId(`usage-host-${getServerId()}`);
+ await expect(group.getByText("41%", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
+}
+
+test.describe("usage modal", () => {
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
page,
}) => {
@@ -134,14 +170,13 @@ test.describe("usage screen", () => {
});
await gotoAppShell(page);
- await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
- await expect(page).toHaveURL(/\/usage$/);
+ await openUsageFromItem(page);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
- await qaScreenshot(page, "phase7-usage-screen-one-host");
+ await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -151,6 +186,21 @@ test.describe("usage screen", () => {
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
});
+ for (const width of [1440, 390]) {
+ test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
+ page,
+ }) => {
+ const usage = await installReversedUsageRefresh(page);
+ await openUsageSummaryAtWidth(page, width);
+ await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
+ await qaScreenshot(page, `usage-order-${width}-before-refresh`);
+
+ await refreshUsageAndWaitForReports(page, usage);
+ await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
+ await qaScreenshot(page, `usage-order-${width}-after-refresh`);
+ });
+ }
+
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -176,7 +226,7 @@ test.describe("usage screen", () => {
windows: [{ id: "weekly", label: "Weekly", usedPct }],
},
});
- // The sidebar summary and the screen each load reports; only a card's Refresh forces one.
+ // The sidebar summary and the modal each load reports; only a card's Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
(request) =>
@@ -187,7 +237,7 @@ test.describe("usage screen", () => {
});
await gotoAppShell(page);
- await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
+ await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
@@ -224,7 +274,9 @@ test.describe("usage screen", () => {
});
await page.setViewportSize({ width: 390, height: 844 });
- await page.goto("/usage");
+ await gotoAppShell(page);
+ await openCompactSidebar(page);
+ await openUsageFromIcon(page);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
@@ -238,8 +290,8 @@ test.describe("usage screen", () => {
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
- // Without reports the footer has no Usage item; its Usage icon opens the screen.
- await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
+ // Without reports the footer has no Usage item; its Usage icon opens Usage.
+ await openUsageFromIcon(page);
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
@@ -250,7 +302,7 @@ test.describe("usage screen", () => {
expect(usage.listRequests()).toHaveLength(0);
});
- test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
+ test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
page,
}) => {
test.setTimeout(420_000);
@@ -273,7 +325,7 @@ test.describe("usage screen", () => {
// Nothing picked and no workspace open: the first host.
await expectPinnedUsage(page, ["31% Weekly"]);
- await usageItem(page).click();
+ await openUsageFromItem(page);
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await hostFilter(page).click();
@@ -282,7 +334,7 @@ test.describe("usage screen", () => {
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
- await qaScreenshot(page, "usage-screen-picked-host");
+ await qaScreenshot(page, "usage-modal-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
@@ -292,6 +344,7 @@ test.describe("usage screen", () => {
port: secondary.port,
});
await expectPinnedUsage(page, ["12% Weekly"]);
+ await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
@@ -320,7 +373,7 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
});
try {
await gotoAppShell(page);
- await openUsage(page);
+ await openUsageFromIcon(page);
await expect(
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toBeVisible();
@@ -366,7 +419,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
try {
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
- await openUsage(page);
+ await openCompactSidebar(page);
+ await openUsageFromIcon(page);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
@@ -413,7 +467,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expect(usageItem(page)).toBeVisible();
- await usageItem(page).click();
+ await openUsageFromItem(page);
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
await expectCodexReportedWindows(page, shape);
await qaScreenshot(page, `codex-${shape}-pins`);
@@ -439,7 +493,9 @@ for (const theme of ["light", "dark"] as const) {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize(viewport);
- await page.goto("/usage");
+ await gotoAppShell(page);
+ if (size === "compact") await openCompactSidebar(page);
+ await openUsageFromIcon(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = page.getByTestId("usage-options-menu");
@@ -501,7 +557,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
]);
try {
await gotoAppShell(page);
- await openUsage(page);
+ await openUsageFromIcon(page);
await expectLoginErrors(page, [
"Codex: Usage API returned 500. Try again.",
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts
index 2b8d982de7..1b509f5640 100644
--- a/packages/app/e2e/browser/usage-settings.spec.ts
+++ b/packages/app/e2e/browser/usage-settings.spec.ts
@@ -7,7 +7,11 @@ import {
installUsageReportsFixture,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
-import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
+import {
+ openUsageFromIcon,
+ refreshAllUsage,
+ showUsageAs,
+} from "../support/helpers/usage-sidebar-item";
const ICON = ' ';
@@ -98,7 +102,8 @@ test.describe("usage settings", () => {
// The shared percentages setting applies to the host section.
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
const hostUsageUrl = page.url();
- await page.goto("/usage");
+ await gotoAppShell(page);
+ await openUsageFromIcon(page);
await showUsageAs(page, "remaining");
await page.goto(hostUsageUrl);
await expect(card.getByText("30% left")).toBeVisible();
diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts
index e43ccdc4a3..27fac6691c 100644
--- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts
+++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts
@@ -2,6 +2,11 @@ import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
+import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher";
+import {
+ connectNewWorkspaceDaemonClient,
+ openProjectViaDaemon,
+} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
@@ -14,28 +19,29 @@ import {
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
- closeUsageSheet,
+ closeUsage,
+ closeUsageFromBackdrop,
closeUsageOptions,
- openUsageSheetFromIcon,
expectNoUsageItem,
- expectOnUsageScreen,
expectPinnedUsage,
expectSummaryInSidebar,
expectUnpinnableRows,
- leaveUsageScreen,
openCompactSidebar,
- openUsageScreenFromIcon,
+ openUsageFromIcon,
+ openUsageFromItem,
pinRow,
+ scrollUsageToWindow,
setSummaryInSidebar,
openUsageOptions,
showUsageAs,
togglePin,
usageItem,
- usageSheet,
+ usageModal,
installTallUsageSource,
} from "../support/helpers/usage-sidebar-item";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
+import { createTempGitRepo } from "../support/helpers/workspace";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -75,49 +81,98 @@ test.describe("Usage item", () => {
await expectNoUsageItem(page);
await test.step("the icon opens a sheet without leaving the current screen", async () => {
- await openUsageSheetFromIcon(page);
- await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
+ await openUsageFromIcon(page);
+ await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-icon-sheet");
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
- await closeUsageSheet(page);
+ await closeUsageFromBackdrop(page);
});
await test.step("the summary opens the same sheet", async () => {
- await usageItem(page).click();
- await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
+ await openUsageFromItem(page);
+ await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
- await closeUsageSheet(page);
+ await closeUsageFromBackdrop(page);
});
});
- test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
+
+ test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({
page,
}) => {
- test.setTimeout(120_000);
- const source = await installTallUsageSource();
+ const repo = await createTempGitRepo("usage-modal-");
+ const client = await connectNewWorkspaceDaemonClient();
try {
- await page.setViewportSize(COMPACT);
+ const project = await openProjectViaDaemon(client, repo.path);
+ await page.setViewportSize(WIDE);
+ await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
- await gotoAppShell(page);
- await openCompactSidebar(page);
- await expect(usageItem(page)).toBeInViewport();
- await usageItem(page).click();
- const content = page.getByTestId("sidebar-usage-sheet-content");
- await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
- await waitForSettledPosition(content);
- const bounds = (await content.boundingBox())!;
- expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
- await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
- await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
- await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
- await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
- await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
+ await gotoWorkspace(page, project.workspaceId);
+ const workspaceUrl = page.url();
+
+ await test.step("the summary opens a dialog over the workspace", async () => {
+ await openUsageFromItem(page);
+ const dialog = page.getByRole("dialog");
+ await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
+ await expect(
+ dialog.getByRole("button", { name: "Refresh all", exact: true }),
+ ).toBeVisible();
+ await expect(page).toHaveURL(workspaceUrl);
+ await qaScreenshot(page, "desktop-usage-dialog");
+ });
+
+ await test.step("Close returns to the same workspace", async () => {
+ await closeUsage(page);
+ await expect(page).toHaveURL(workspaceUrl);
+ await waitForTabBar(page);
+ });
+
+ await test.step("the icon opens it again, and Escape closes it", async () => {
+ await openUsageFromIcon(page);
+ await page.keyboard.press("Escape");
+ await expect(usageModal(page)).toHaveCount(0);
+ await expect(page).toHaveURL(workspaceUrl);
+ });
} finally {
- await source.cleanup();
+ await client.close().catch(() => undefined);
+ await repo.cleanup().catch(() => undefined);
}
});
+
+ const layouts = {
+ desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} },
+ compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
+ };
+ for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
+ test(`long usage reports scroll between their first and last windows on ${size}`, async ({
+ page,
+ }) => {
+ test.setTimeout(120_000);
+ const source = await installTallUsageSource();
+ try {
+ await page.setViewportSize(viewport);
+ await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
+ await gotoAppShell(page);
+ await showSidebar(page);
+ await expect(usageItem(page)).toBeInViewport();
+ await openUsageFromItem(page);
+ const content = usageModal(page);
+ await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
+ await waitForSettledPosition(content);
+ await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
+ await qaScreenshot(page, `${size}-usage-scroll-top`);
+ await scrollUsageToWindow(page, "Window 20");
+ await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
+ await qaScreenshot(page, `${size}-usage-scroll-bottom`);
+ await scrollUsageToWindow(page, "Window 1");
+ await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
+ } finally {
+ await source.cleanup();
+ }
+ });
+ }
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
page,
}) => {
@@ -146,8 +201,7 @@ test.describe("Usage item", () => {
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
- await usageItem(page).click();
- await expectOnUsageScreen(page);
+ await openUsageFromItem(page);
await qaScreenshot(page, "default-pins-card");
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
@@ -172,17 +226,13 @@ test.describe("Usage item", () => {
await expect(usageItem(page)).not.toHaveText("Usage");
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
- await qaScreenshot(page, "usage-screen-header", {
- kind: "element",
- locator: page.getByTestId("page-title").locator(".."),
- });
await qaScreenshot(page, "usage-card-refresh", {
kind: "element",
locator: screen.getByTestId("usage-report-claude:default"),
});
});
- await test.step("remaining flips the Usage item and the Usage screen", async () => {
+ await test.step("remaining flips the Usage item and the Usage modal", async () => {
await showUsageAs(page, "remaining");
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
@@ -194,23 +244,15 @@ test.describe("Usage item", () => {
).toBeVisible();
});
- await test.step("on desktop the Usage item opens the Usage screen", async () => {
- await gotoAppShell(page);
- await expect(page).not.toHaveURL(/\/usage$/);
- await usageItem(page).click();
- await expectOnUsageScreen(page);
- await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
- });
-
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
+ await closeUsage(page);
await page.setViewportSize(COMPACT);
- await leaveUsageScreen(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await qaScreenshot(page, "compact-footer");
- await usageItem(page).click();
- const sheet = usageSheet(page);
+ await openUsageFromItem(page);
+ const sheet = usageModal(page);
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
await expect(sheet.getByText("88% left")).toBeVisible();
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
@@ -231,25 +273,20 @@ test.describe("Usage item", () => {
await closeUsageOptions(page);
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
- await expect(page).not.toHaveURL(/\/usage$/);
await waitForSettledPosition(sheet);
const sheetBox = (await sheet.boundingBox())!;
- // The Usage sheet reserves the top 20% as its backdrop.
+ // The sheet leaves the screen above it as its backdrop.
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
- await qaScreenshot(page, "phase7-compact-sheet");
- // Tap the backdrop above the sheet.
- await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
- await expect(sheet).toHaveCount(0);
+ await closeUsageFromBackdrop(page);
await page.setViewportSize(WIDE);
- await usageItem(page).click();
- await expectOnUsageScreen(page);
});
await test.step("a reload keeps the pins and the toggle", async () => {
await page.reload();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
+ await openUsageFromItem(page);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
@@ -260,13 +297,15 @@ test.describe("Usage item", () => {
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
- const usageUrl = page.url();
+ const workspaceUrl = page.url();
+ await closeUsage(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const section = page.getByTestId("usage-card");
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
- await page.goto(usageUrl);
+ await page.goto(workspaceUrl);
+ await openUsageFromItem(page);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
@@ -275,6 +314,7 @@ test.describe("Usage item", () => {
await togglePin(screen, "Codex", "Weekly");
await expectNoUsageItem(page);
await page.reload();
+ await openUsageFromIcon(page);
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectNoUsageItem(page);
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
@@ -282,6 +322,7 @@ test.describe("Usage item", () => {
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
await qaScreenshot(page, "explicit-empty-pins-after-reload");
+ await closeUsage(page);
});
await test.step("Settings > Sidebar lists the Usage item", async () => {
@@ -325,8 +366,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
await expectNoUsageItem(page);
await expectFooterSeparator(page, false);
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
- await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
- await expectOnUsageScreen(page);
+ await openUsageFromIcon(page);
});
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
@@ -340,13 +380,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
const claude = screen.getByTestId("usage-report-claude:default");
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
- await openUsageScreenFromIcon(page);
+ await openUsageFromIcon(page);
await expectNoUsageItem(page);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
await expectSummaryInSidebar(page, false);
- await qaScreenshot(page, "usage-screen-summary-off");
+ await qaScreenshot(page, "usage-summary-off");
});
await test.step("on: the Usage item and the pins appear", async () => {
@@ -355,7 +395,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
- await qaScreenshot(page, "usage-screen-summary-on");
+ await qaScreenshot(page, "usage-summary-on");
});
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
@@ -363,7 +403,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
await expectNoUsageItem(page);
await expectUnpinnableRows(claude);
await page.reload();
- await expectOnUsageScreen(page);
+ await openUsageFromIcon(page);
await expectSummaryInSidebar(page, false);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
@@ -391,8 +431,7 @@ test("released hosts supply source logos through the client conversion", async (
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
- await usageItem(page).click();
- await expectOnUsageScreen(page);
+ await openUsageFromItem(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(
screen.getByTestId("usage-report-claude").locator("svg path").first(),
@@ -400,5 +439,5 @@ test("released hosts supply source logos through the client conversion", async (
await expect(
screen.getByTestId("usage-report-codex").locator("svg path").first(),
).toHaveAttribute("d", codexPath);
- await qaScreenshot(page, "released-host-usage-screen");
+ await qaScreenshot(page, "released-host-usage-modal");
});
diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
new file mode 100644
index 0000000000..4853e9daa4
--- /dev/null
+++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
@@ -0,0 +1,74 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "../support/fixtures";
+import { gotoAppShell } from "../support/helpers/app";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { getServerId } from "../support/helpers/server-id";
+import {
+ expectSidebarWorkspaceSelected,
+ switchWorkspaceViaSidebar,
+} from "../support/helpers/workspace-ui";
+
+async function readVisibleWorkspaceOrder(page: Page): Promise {
+ const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
+ await expect(rows).toHaveCount(12);
+ return rows.evaluateAll(
+ (elements, prefix) =>
+ elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
+ `sidebar-workspace-row-${getServerId()}:`,
+ );
+}
+
+async function navigateWorkspace(
+ page: Page,
+ direction: "next" | "previous",
+ workspaceId: string,
+): Promise {
+ await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
+ await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
+ await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
+}
+
+test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ const project = await seedWorkspace({
+ repoPrefix: "relative-navigation-",
+ title: "Workspace 01",
+ });
+ try {
+ for (let index = 2; index <= 12; index += 1) {
+ const created = await project.client.createWorkspace({
+ source: { kind: "directory", path: project.repoPath },
+ title: `Workspace ${String(index).padStart(2, "0")}`,
+ });
+ expect(created.error).toBeNull();
+ }
+ await page.setViewportSize({ width: 1440, height: 1100 });
+ await gotoAppShell(page);
+ const order = await readVisibleWorkspaceOrder(page);
+
+ await test.step("next crosses the ninth workspace and previous returns to it", async () => {
+ await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
+ await navigateWorkspace(page, "next", order[9]);
+ await navigateWorkspace(page, "previous", order[8]);
+ });
+
+ await test.step("wrapping uses the true first and last visible workspace", async () => {
+ await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
+ await navigateWorkspace(page, "previous", order[11]);
+ await navigateWorkspace(page, "next", order[0]);
+ });
+
+ await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
+ await page.keyboard.press("Alt+9");
+ await expectSidebarWorkspaceSelected({
+ page,
+ serverId: getServerId(),
+ workspaceId: order[8],
+ });
+ });
+ } finally {
+ await project.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts
index 07e62131b0..72b5908446 100644
--- a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts
+++ b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts
@@ -1,3 +1,4 @@
+import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
+test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
+
+async function openTabSwitcher(page: Page) {
+ await page.getByRole("button", { name: /Switch tabs/ }).click();
+ const footer = page.getByRole("button", { name: "New terminal", exact: true });
+ await expect(footer).toBeVisible();
+ // Visibility alone passes while the sheet is still travelling up from below the viewport.
+ await expect
+ .poll(async () => {
+ const bounds = await footer.boundingBox();
+ return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
+ })
+ .toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
+}
+
+async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
+ await page.screenshot({ path: testInfo.outputPath(name + ".png") });
+}
+
+async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
+ await openTabSwitcher(page);
+ await page.getByRole("button", { name: kind, exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
test.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({
page,
- }) => {
+ }, testInfo) => {
test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT);
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000,
});
+ await captureScreen(page, testInfo, "compact-chat-header");
+
// Open the sessions dropdown (a bottom sheet on compact).
- await page.getByRole("button", { name: /Switch tabs/ }).click();
+ await openTabSwitcher(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
- await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
+ await expect(page.getByText(renamed, { exact: true })).toBeVisible({
timeout: 15_000,
});
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
+ await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
+ await captureScreen(page, testInfo, "renamed-tab-in-switcher");
} finally {
await session.cleanup();
}
});
});
+
+test("switcher footer creates tabs and returns to the existing chat", async ({
+ page,
+}, testInfo) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "compact-header-actions-",
+ title: "Existing chat",
+ });
+ try {
+ await openAgentRoute(page, session);
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
+
+ await createTabFromSwitcher(page, "New terminal");
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
+ await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
+ await captureScreen(page, testInfo, "compact-terminal-header");
+
+ await createTabFromSwitcher(page, "New agent");
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
+ await expect(
+ page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
+ ).toHaveCount(1);
+ await captureScreen(page, testInfo, "compact-draft-header");
+
+ await openTabSwitcher(page);
+ await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
+ await captureScreen(page, testInfo, "switcher-three-tabs");
+ await page.getByText("Existing chat", { exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
+
+ await openTabSwitcher(page);
+ await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .click({ position: { x: 10, y: 10 } });
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+ await captureScreen(page, testInfo, "returned-chat-header");
+ } finally {
+ await session.cleanup();
+ }
+});
diff --git a/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md
new file mode 100644
index 0000000000..56dbd6355e
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md
@@ -0,0 +1,15 @@
+# Native compact workspace scripts flow
+
+`native-compact-workspace-scripts.android.ad` covers live script status in the compact ⋯ → Scripts sheet when nothing else keeps the host directory live. Start on a phone with a workspace open, the sidebar closed, and no agent tab selected. A viewed agent tab or the open sidebar hides the failure, because either one keeps the directory subscribed.
+
+The workspace's `paseo.json` needs a long-running plain script, stopped before the run. `SCRIPT` defaults to `worker`:
+
+```json
+{ "scripts": { "worker": { "command": "node -e \"setInterval(() => {}, 1000)\"" } } }
+```
+
+The flow starts the script, checks that Stop flips its page to Run, runs it again, and checks that Restart brings it back to running with no other interaction. It ends with the script stopped and the sheet dismissed, so it can be replayed.
+
+```sh
+agent-device replay packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad --platform android --serial emulator-5590 --metro-port 8137
+```
diff --git a/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad
new file mode 100644
index 0000000000..7a24b7ee8b
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad
@@ -0,0 +1,45 @@
+context platform=android timeout=120000 retries=0
+env APP_ID=sh.paseo.debug
+env SCRIPT=worker
+
+open "${APP_ID}"
+wait "id=\"workspace-header-menu-trigger\"" 45000
+wait stable 1000 15000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "id=\"workspace-scripts-row-${SCRIPT}\"" 10000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait stable 1000 15000
+wait "id=\"workspace-header-menu-trigger\"" 10000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "text" "Running" 10000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
+press "id=\"workspace-script-${SCRIPT}-stop\""
+wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
+press "id=\"workspace-script-${SCRIPT}-run\""
+wait stable 1000 15000
+wait "id=\"workspace-header-menu-trigger\"" 10000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "text" "Running" 10000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait "id=\"workspace-script-${SCRIPT}-restart\"" 10000
+press "id=\"workspace-script-${SCRIPT}-restart\""
+wait stable 1000 15000
+wait "id=\"workspace-header-menu-trigger\"" 10000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "text" "Running" 15000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
+press "id=\"workspace-script-${SCRIPT}-stop\""
+wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
+back
+wait stable 1000 15000
+close
diff --git a/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md b/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md
new file mode 100644
index 0000000000..ac920aa0e7
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md
@@ -0,0 +1,7 @@
+# Native question option preview
+
+`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
+
+```sh
+agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
+```
diff --git a/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad b/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad
new file mode 100644
index 0000000000..f6cd42bbab
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad
@@ -0,0 +1,10 @@
+context platform=android timeout=60000 retries=0
+env APP_ID=sh.paseo.debug
+
+open "${APP_ID}"
+wait "label=\"rollout\"" 20000
+press "label=\"rollout\""
+wait "id=\"question-form-option-preview\"" 10000
+gesture pan 570 1120 0 -440 600
+is visible "text=\"// LAST PREVIEW LINE\""
+close
diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad
index 63f5fb0728..335f269b13 100644
--- a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
-wait "id=\"workspace-header-menu-trigger\"" 20000
-press "id=\"workspace-header-menu-trigger\""
-press "id=\"workspace-header-new-terminal\""
+wait "id=\"workspace-tab-switcher-trigger\"" 20000
+press "id=\"workspace-tab-switcher-trigger\""
+press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad
index 4988c9afdd..2b5e5fcfdb 100644
--- a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
-wait "id=\"workspace-header-menu-trigger\"" 20000
-press "id=\"workspace-header-menu-trigger\""
-press "id=\"workspace-header-new-terminal\""
+wait "id=\"workspace-tab-switcher-trigger\"" 20000
+press "id=\"workspace-tab-switcher-trigger\""
+press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad
index 9ef099b5a7..11f75b62b3 100644
--- a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
-wait "id=\"workspace-header-menu-trigger\"" 20000
-press "id=\"workspace-header-menu-trigger\""
-press "id=\"workspace-header-new-terminal\""
+wait "id=\"workspace-tab-switcher-trigger\"" 20000
+press "id=\"workspace-tab-switcher-trigger\""
+press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh
index ba86400132..49c907dba9 100755
--- a/packages/app/e2e/mobile/composer-keyboard/android.sh
+++ b/packages/app/e2e/mobile/composer-keyboard/android.sh
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
ad keyboard dismiss || true
-ad press 'id="workspace-header-menu-trigger"' --settle
-ad wait 'id="workspace-header-new-agent"' 10000
-ad press 'id="workspace-header-new-agent"' --settle
+ad press 'id="workspace-tab-switcher-trigger"' --settle
+ad wait 'id="workspace-tab-switcher-new-agent"' 10000
+ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
diff --git a/packages/app/e2e/mobile/native-text-reflow/README.md b/packages/app/e2e/mobile/native-text-reflow/README.md
deleted file mode 100644
index b92666cc58..0000000000
--- a/packages/app/e2e/mobile/native-text-reflow/README.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Native iOS text geometry regression
-
-Run on the Namespace Mac with a fresh simulator development build. Use the PID
-returned by `xcrun simctl launch sh.paseo.debug`.
-Attach only to your task's app, while no device replay is running:
-
-```sh
-PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
-PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
-bash packages/app/e2e/mobile/native-text-reflow/ios.sh
-```
-
-The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
-and resizes its content container from 500 to 240 and back to 500 points. It
-asserts the inner frame follows both widths and the same selection survives.
-It uses public UIView/UITextView operations, without mocking React Native.
-LLDB detaches after the probe. An assertion failure or evaluation error fails the
-shell command; the log distinguishes them.
-
-This covers native container resizing and selection retention. It does not cover
-Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
-menu. Those require the real chat journey: read an older multiline response,
-open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
-retain expanded/collapsed details, and check a stationary reader's position.
-Accessibility exposes full text even when glyphs are clipped, so a text wait
-alone does not verify that journey.
diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.py b/packages/app/e2e/mobile/native-text-reflow/ios.py
deleted file mode 100644
index 7a4cbd5125..0000000000
--- a/packages/app/e2e/mobile/native-text-reflow/ios.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import lldb
-
-
-def __lldb_init_module(debugger, _internal_dict):
- debugger.HandleCommand("expression -l objc++ -- @import UIKit")
- frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
- options = lldb.SBExpressionOptions()
- options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
- result = frame.EvaluateExpression(
- r'''({
- UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
- auto hostFrame = host.frame;
- hostFrame.size.width = 500;
- hostFrame.size.height = 200;
- host.frame = hostFrame;
- UIView *container = (UIView *)[host performSelector:@selector(contentView)];
- container.frame = host.bounds;
- UITextView *text = nil;
- for (UIView *child in host.subviews) {
- if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
- for (UIView *nested in child.subviews) {
- if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
- }
- }
- text.frame = container.bounds;
- text.font = [UIFont systemFontOfSize:16];
- text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
- auto range = text.selectedRange;
- range.location = 12;
- range.length = 9;
- text.selectedRange = range;
- auto frame = container.frame;
- frame.size.width = 240;
- container.frame = frame;
- [container layoutIfNeeded];
- CGFloat narrow = text.frame.size.width;
- auto selection = text.selectedRange;
- frame.size.width = 500;
- container.frame = frame;
- [container layoutIfNeeded];
- CGFloat wide = text.frame.size.width;
- BOOL ok = text != nil && narrow == 240 && wide == 500 &&
- selection.location == text.selectedRange.location &&
- selection.length == text.selectedRange.length &&
- selection.location == 12 && selection.length == 9;
- [NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
- ok ? @"PASS" : @"FAIL", narrow, wide,
- (unsigned long)text.selectedRange.location,
- (unsigned long)text.selectedRange.length];
- })''',
- options,
- )
- if result.GetError().Fail():
- raise RuntimeError(result.GetError().GetCString())
- print(result.GetObjectDescription())
diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.sh b/packages/app/e2e/mobile/native-text-reflow/ios.sh
deleted file mode 100644
index b4e9a07a22..0000000000
--- a/packages/app/e2e/mobile/native-text-reflow/ios.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
-: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
-
-if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
- echo "Expected a running PaseoDebug simulator app" >&2
- exit 1
-fi
-
-probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
- -o "command script import $probe_dir/ios.py" \
- -o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
-
-grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
-grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
diff --git a/packages/app/e2e/mobile/pair-device/android.mjs b/packages/app/e2e/mobile/pair-device/android.mjs
new file mode 100644
index 0000000000..7ab660c2b0
--- /dev/null
+++ b/packages/app/e2e/mobile/pair-device/android.mjs
@@ -0,0 +1,43 @@
+// Start on Settings > Host > Pair device with relay enabled in the Android app.
+// node packages/app/e2e/mobile/pair-device/android.mjs
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+
+const [, , session] = process.argv;
+assert(session, "Pass the agent-device session for the connected Android app");
+
+function ad(...args) {
+ return execFileSync("agent-device", [...args, "--session", session], {
+ encoding: "utf8",
+ timeout: 60_000,
+ });
+}
+
+ad(
+ "press",
+ 'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
+ "--settle",
+);
+ad("wait", "text", "Pairing QR code", "10000");
+const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
+const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
+const qr = data.nodes.find((node) => node.label === "Pairing QR code");
+assert(sheet?.rect, "Pairing sheet must be visible");
+assert(qr?.rect, "Pairing QR code must be visible");
+assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
+assert(
+ qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
+ "QR must have an unclipped right margin inside the sheet",
+);
+assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
+assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
+assert(
+ qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
+ "QR bottom must be visible inside the sheet",
+);
+assert(
+ data.nodes.some((node) => node.label === "Copy" && node.enabled),
+ "Copy must remain available",
+);
+console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
+ad("press", 'label="Close"', "--settle");
diff --git a/packages/app/e2e/mobile/system-bars/android.mjs b/packages/app/e2e/mobile/system-bars/android.mjs
new file mode 100644
index 0000000000..8201f3dc12
--- /dev/null
+++ b/packages/app/e2e/mobile/system-bars/android.mjs
@@ -0,0 +1,62 @@
+// Open Settings > Appearance in a native Android app loaded from this checkout.
+// AGENT_DEVICE_STATE_DIR= node packages/app/e2e/mobile/system-bars/android.mjs
+// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { setTimeout as delay } from "node:timers/promises";
+
+const [, , session, serial, appId] = process.argv;
+assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
+
+function ad(...args) {
+ return execFileSync("agent-device", [...args, "--session", session], {
+ encoding: "utf8",
+ timeout: 60_000,
+ });
+}
+
+function windowState() {
+ const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
+ encoding: "utf8",
+ timeout: 10_000,
+ });
+ assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
+ const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
+ assert(regions, "Android must report the status-bar appearance region");
+ return regions.includes("LIGHT_STATUS_BARS");
+}
+
+async function expectDarkIcons(expected, context) {
+ const deadline = Date.now() + 5_000;
+ do {
+ if (windowState() === expected) {
+ console.log(`PASS: ${context}`);
+ return;
+ }
+ await delay(100);
+ } while (Date.now() < deadline);
+ assert.equal(windowState(), expected, context);
+}
+
+function selectTheme(theme) {
+ const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
+ assert(current, "Start on Settings > Appearance");
+ ad("press", `label="Theme: ${current}"`, "--settle");
+ ad("press", `label="${theme}"`, "--settle");
+ ad("wait", "text", `Theme: ${theme}`, "5000");
+}
+
+ad("settings", "appearance", "light");
+selectTheme("Light");
+await expectDarkIcons(true, "Light app theme uses dark status icons");
+selectTheme("Dark");
+await expectDarkIcons(false, "Dark app theme uses light status icons");
+selectTheme("System");
+await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
+ad("settings", "appearance", "dark");
+await expectDarkIcons(
+ false,
+ "System theme follows an OS change to dark without reopening settings",
+);
+ad("settings", "appearance", "light");
+await expectDarkIcons(true, "System theme follows an OS change back to light");
diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts
index 33308ee386..9608a95a0c 100644
--- a/packages/app/e2e/support/creation-fixtures.ts
+++ b/packages/app/e2e/support/creation-fixtures.ts
@@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise; fail(): Promise };
+ promptRejection: void;
creation: Awaited>;
promptRetry: ReturnType;
agentRetries: Awaited>;
@@ -65,6 +66,37 @@ export default function contribute(server) {
await client.close();
}
},
+ promptRejection: async ({ page }, provide) => {
+ void page;
+ const client = await connectNewWorkspaceDaemonClient();
+ const plugin = await copyPluginExample("lifecycle-logger");
+ const previous = await client.getDaemonConfig();
+ try {
+ await writeFile(
+ path.join(plugin.directory, "index.server.ts"),
+ `
+export default function contribute(server) {
+ return server.before("agent.create", ({ request }) => ({
+ ...request,
+ config: {
+ ...request.config,
+ model: "e2e-fast-stream",
+ featureValues: { ...request.config.featureValues, mockPromptRejections: 1 },
+ },
+ }));
+}
+`,
+ );
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(plugin.directory);
+ await provide();
+ } finally {
+ await client.removePlugin("lifecycle-logger");
+ await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
+ await plugin.cleanup();
+ await client.close();
+ }
+ },
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
diff --git a/packages/app/e2e/support/helpers/add-project-flow.ts b/packages/app/e2e/support/helpers/add-project-flow.ts
index bb88264d37..666469540e 100644
--- a/packages/app/e2e/support/helpers/add-project-flow.ts
+++ b/packages/app/e2e/support/helpers/add-project-flow.ts
@@ -90,3 +90,26 @@ export async function expectNewWorkspaceForAddedProject(
{ timeout: 30_000 },
);
}
+
+export async function navigateAddProjectResults(
+ page: Page,
+ key: "ArrowDown" | "ArrowUp",
+ count: number,
+): Promise {
+ for (let index = 0; index < count; index += 1) await page.keyboard.press(key);
+}
+
+export async function expectAddProjectResultVisible(page: Page, text: string): Promise {
+ const results = page.getByTestId("add-project-flow-results");
+ const row = results.getByRole("button").filter({ has: page.getByText(text, { exact: true }) });
+ await expect
+ .poll(async () => {
+ const viewport = await results.boundingBox();
+ const bounds = await row.boundingBox();
+ if (!viewport || !bounds) return false;
+ return (
+ bounds.y >= viewport.y - 1 && bounds.y + bounds.height <= viewport.y + viewport.height + 1
+ );
+ })
+ .toBe(true);
+}
diff --git a/packages/app/e2e/support/helpers/agent-message-gate.ts b/packages/app/e2e/support/helpers/agent-message-gate.ts
index 7444fee796..9f5735d5ac 100644
--- a/packages/app/e2e/support/helpers/agent-message-gate.ts
+++ b/packages/app/e2e/support/helpers/agent-message-gate.ts
@@ -6,6 +6,8 @@ type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
+ text: unknown;
+ attachments: unknown;
agentId: string;
/** Absent when the client sends into an idle agent. */
activeTurnBehavior?: string;
@@ -29,6 +31,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
return {
type: "send_agent_message_request",
requestId: request.requestId,
+ text: request.text,
+ attachments: request.attachments,
agentId: request.agentId,
activeTurnBehavior:
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
@@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) {
return {
waitForRequest,
+ requestCount: () => requests.length,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts
index 63bc182eaf..c4200c7c75 100644
--- a/packages/app/e2e/support/helpers/assistant-images.ts
+++ b/packages/app/e2e/support/helpers/assistant-images.ts
@@ -87,10 +87,11 @@ export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
+ destination: string = image.relativePath,
): Promise {
await client.sendAgentMessage(
agent.id,
- `Emit settled assistant image Markdown: `,
+ `Emit settled assistant image Markdown: `,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts
index 7595b4eb20..45bdc1aded 100644
--- a/packages/app/e2e/support/helpers/codex-workspace-restore.ts
+++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
- background: true,
}),
);
agentId = agent.agentId;
diff --git a/packages/app/e2e/support/helpers/compact-workspace-scripts.ts b/packages/app/e2e/support/helpers/compact-workspace-scripts.ts
new file mode 100644
index 0000000000..b01a527bc6
--- /dev/null
+++ b/packages/app/e2e/support/helpers/compact-workspace-scripts.ts
@@ -0,0 +1,61 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/** The compact ⋯ sheet. Its trigger shares "Workspace actions" with the sidebar row kebabs. */
+export function compactWorkspaceMenu(page: Page): Locator {
+ return page.getByTestId("workspace-header-menu-content");
+}
+
+export async function openCompactWorkspaceMenu(page: Page): Promise {
+ await page.getByTestId("workspace-header-menu-trigger").click();
+ await expect(compactWorkspaceMenu(page)).toBeVisible({ timeout: 10_000 });
+}
+
+export function menuRow(page: Page, name: string | RegExp): Locator {
+ return compactWorkspaceMenu(page).getByRole("menuitem", { name });
+}
+
+/** The root row that leads to the Scripts page; its accessible name carries its value. */
+export function scriptsMenuRow(page: Page): Locator {
+ return menuRow(page, /^Scripts/);
+}
+
+/** A row on the Scripts page. A running script's name is followed by "Running". */
+export function scriptRow(page: Page, scriptName: string): Locator {
+ return menuRow(page, new RegExp(`^${scriptName}`));
+}
+
+export async function openScriptsPage(page: Page): Promise {
+ await openCompactWorkspaceMenu(page);
+ await scriptsMenuRow(page).click();
+ await expect(scriptsMenuRow(page)).toHaveCount(0);
+ await expect(backButton(page)).toBeVisible();
+}
+
+export async function openRunningScriptPage(page: Page, scriptName: string): Promise {
+ await openScriptsPage(page);
+ await scriptRow(page, scriptName).click();
+ await expect(menuRow(page, "Stop")).toBeVisible();
+}
+
+/** Taps a stopped script on the Scripts page, which runs it and closes the sheet. */
+export async function runScriptFromScriptsPage(page: Page, scriptName: string): Promise {
+ await openScriptsPage(page);
+ await scriptRow(page, scriptName).click();
+ await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
+}
+
+export async function goBackInMenu(page: Page): Promise {
+ await backButton(page).click();
+}
+
+export async function closeCompactWorkspaceMenu(page: Page): Promise {
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .first()
+ .click({ position: { x: 12, y: 12 } });
+ await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
+}
+
+function backButton(page: Page): Locator {
+ return compactWorkspaceMenu(page).getByRole("button", { name: "Back" });
+}
diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts
index de8ddf0c9b..d4ef8fc2f4 100644
--- a/packages/app/e2e/support/helpers/composer.ts
+++ b/packages/app/e2e/support/helpers/composer.ts
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise {
await expect(composerInput(page)).not.toBeFocused();
}
+/** Blur the editor without anything else taking focus. */
+export async function blurComposerInput(page: Page): Promise {
+ await composerInput(page).blur();
+ await expectComposerNotFocused(page);
+}
+
export async function submitMessage(page: Page, text: string): Promise {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -316,3 +322,182 @@ export async function openGithubWorkspace(
},
};
}
+
+const COMPOSER_TEST_IMAGE_BASE64 =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+export async function attachComposerTestImage(page: Page): Promise {
+ await attachImageFromMenu(page, {
+ name: "fullscreen.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
+ });
+}
+
+export async function pasteComposerTestImage(page: Page): Promise {
+ return composerInput(page).evaluate((input, base64) => {
+ const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
+ const clipboardData = new DataTransfer();
+ clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
+ const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
+ input.dispatchEvent(event);
+ return event.defaultPrevented;
+ }, COMPOSER_TEST_IMAGE_BASE64);
+}
+
+export async function enterComposerFullscreen(page: Page): Promise {
+ await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
+}
+export async function exitComposerFullscreen(page: Page): Promise {
+ await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
+}
+export async function resizeComposerViewport(
+ page: Page,
+ orientation: "portrait" | "landscape" | "short",
+): Promise {
+ const sizes = {
+ portrait: { width: 390, height: 844 },
+ landscape: { width: 844, height: 390 },
+ short: { width: 390, height: 390 },
+ };
+ await page.setViewportSize(sizes[orientation]);
+ // Before the app has loaded there is no layout to wait for.
+ if (page.url() === "about:blank") return;
+ // Crossing the compact breakpoint remounts the shell and the editor. Anything that
+ // follows (a shortcut, a locator) belongs to the layout the new size implies, so
+ // wait for that layout before returning.
+ await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
+}
+export async function selectComposerDraftRange(
+ page: Page,
+ start: number,
+ end: number,
+): Promise {
+ await composerInput(page).focus();
+ await composerInput(page).evaluate(
+ (element, selection) => {
+ const input = element as HTMLTextAreaElement;
+ input.setSelectionRange(selection.start, selection.end);
+ input.dispatchEvent(new Event("select", { bubbles: true }));
+ },
+ { start, end },
+ );
+}
+export async function expectComposerDraftSelection(
+ page: Page,
+ start: number,
+ end: number,
+): Promise {
+ await expect
+ .poll(() =>
+ composerInput(page).evaluate((element) => {
+ const input = element as HTMLTextAreaElement;
+ return [input.selectionStart, input.selectionEnd];
+ }),
+ )
+ .toEqual([start, end]);
+}
+export async function expectComposerFullscreenControls(page: Page): Promise {
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
+ await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
+ await expect(composerInput(page)).toHaveCount(1);
+}
+/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */
+export async function expectComposerFullscreenFitsVisibleViewport(
+ page: Page,
+ submitLabel = "Send message",
+): Promise {
+ await expect
+ .poll(() =>
+ page.getByTestId("composer-fullscreen").evaluate((element, label) => {
+ const viewport = window.visualViewport!;
+ const frame = element.getBoundingClientRect();
+ const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect();
+ return {
+ frameFits:
+ Math.abs(frame.top - viewport.offsetTop) < 1 &&
+ Math.abs(frame.left - viewport.offsetLeft) < 1 &&
+ Math.abs(frame.height - viewport.height) < 1 &&
+ Math.abs(frame.width - viewport.width) < 1,
+ sendFits:
+ send.top >= viewport.offsetTop &&
+ send.bottom <= viewport.offsetTop + viewport.height &&
+ send.left >= viewport.offsetLeft &&
+ send.right <= viewport.offsetLeft + viewport.width,
+ };
+ }, submitLabel),
+ )
+ .toEqual({ frameFits: true, sendFits: true });
+}
+export async function sendComposerWithButton(page: Page): Promise {
+ await page.getByRole("button", { name: "Send message", exact: true }).click();
+}
+export async function queueComposerWithButton(page: Page): Promise {
+ await page.getByRole("button", { name: "Queue message", exact: true }).click();
+}
+export async function configureComposerQueueSend(page: Page): Promise {
+ await page.addInitScript(() =>
+ localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
+ );
+}
+
+/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
+ * This exercises retained activity changes, not a claim that covered chrome is clickable.
+ */
+/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */
+export async function switchComposerTabWithKeyboard(
+ page: Page,
+ direction: "previous" | "next",
+): Promise {
+ await page.keyboard.press(
+ direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
+ );
+}
+export async function expectWorkspaceHeaderHidden(page: Page): Promise {
+ await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
+}
+export async function expectWorkspaceHeaderVisible(page: Page): Promise {
+ await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
+}
+
+export async function activateRetainedComposerTab(page: Page, title: string): Promise {
+ await page
+ .getByTestId("workspace-tab-switcher-trigger")
+ .evaluate((element) => (element as HTMLElement).click());
+ const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
+ await expect(option).toBeVisible();
+ await option.evaluate((element) => (element as HTMLElement).click());
+}
+
+export async function recordVisibleComposerGeometry(page: Page): Promise {
+ await page.evaluate(() => {
+ const frames: { input: number; root: number }[] = [];
+ Reflect.set(window, "__composerGeometryFrames", frames);
+ let running = true;
+ const tick = () => {
+ if (!running) return;
+ for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
+ if (root.getClientRects().length === 0) continue;
+ const input = root.querySelector("textarea");
+ if (input)
+ frames.push({
+ input: input.getBoundingClientRect().height,
+ root: root.getBoundingClientRect().height,
+ });
+ }
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ Reflect.set(window, "__stopComposerGeometry", () => {
+ running = false;
+ });
+ });
+}
+export async function expectNoCollapsedVisibleComposer(page: Page): Promise {
+ const frames = await page.evaluate(() => {
+ Reflect.get(window, "__stopComposerGeometry")?.();
+ return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
+ });
+ expect(frames.length).toBeGreaterThan(0);
+ expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
+}
diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts
index 09cd2ea290..9098068698 100644
--- a/packages/app/e2e/support/helpers/creation.ts
+++ b/packages/app/e2e/support/helpers/creation.ts
@@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
+ async expectCreatedAgentError() {
+ await requests.settled();
+ await expect(
+ page
+ .getByTestId("assistant-message")
+ .filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
+ ).toBeVisible();
+ await expect(
+ page
+ .locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
+ .filter({ visible: true }),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable();
+ await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
+ },
+ async expectPromptBeforeError(prompt: string) {
+ const messages = page
+ .getByTestId("user-message")
+ .or(page.getByTestId("assistant-message"))
+ .filter({ visible: true });
+ await expect(messages).toHaveCount(2);
+ await expect(messages.nth(0)).toContainText(prompt);
+ await expect(messages.nth(1)).toContainText("[System Error]");
+ await expect(messages.nth(1)).toBeInViewport();
+ },
+ async reloadAgent() {
+ await page.reload();
+ await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
+ await this.expectCreatedAgentError();
+ },
+ async expectAssistantReply() {
+ await expect(
+ page
+ .getByTestId("assistant-message")
+ .filter({ visible: true, hasNotText: "[System Error]" })
+ .first(),
+ ).toBeVisible();
+ },
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
diff --git a/packages/app/e2e/support/helpers/custom-models.ts b/packages/app/e2e/support/helpers/custom-models.ts
new file mode 100644
index 0000000000..11933a0dfa
--- /dev/null
+++ b/packages/app/e2e/support/helpers/custom-models.ts
@@ -0,0 +1,37 @@
+import { expect, type Page } from "@playwright/test";
+import { gotoAppShell, openSettings } from "./app";
+import { openSettingsHostSection } from "./settings";
+import { getServerId } from "./server-id";
+
+export async function openCustomModels(page: Page): Promise {
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHostSection(page, getServerId(), "providers");
+ await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
+ await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
+}
+
+export async function addCustomModel(page: Page, id: string): Promise {
+ await page.getByRole("button", { name: "Add model", exact: true }).click();
+ await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
+ await page
+ .getByTestId("add-custom-model-sheet")
+ .getByRole("button", { name: "Add", exact: true })
+ .click();
+ await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
+}
+
+export async function removeCustomModel(page: Page, id: string): Promise {
+ await page.getByRole("textbox", { name: "Search models" }).fill(id);
+ const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
+ await expect(remove).toBeInViewport({ ratio: 1 });
+ await remove.click();
+ await expect(remove).toHaveCount(0);
+}
+
+export async function expectCustomModelRetained(page: Page, id: string): Promise {
+ await page.getByRole("textbox", { name: "Search models" }).fill(id);
+ await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
+ ratio: 1,
+ });
+}
diff --git a/packages/app/e2e/support/helpers/file-explorer.ts b/packages/app/e2e/support/helpers/file-explorer.ts
index 519f955536..a40adc5ad2 100644
--- a/packages/app/e2e/support/helpers/file-explorer.ts
+++ b/packages/app/e2e/support/helpers/file-explorer.ts
@@ -1,4 +1,4 @@
-import { expect, type Page } from "@playwright/test";
+import { expect, type Download, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise {
+ await fileExplorerEntry(page, fileName).click({ button: "right" });
+ const downloaded = page.waitForEvent("download");
+ await page.getByRole("menuitem", { name: "Download", exact: true }).click();
+ return downloaded;
+}
diff --git a/packages/app/e2e/support/helpers/pair-device.ts b/packages/app/e2e/support/helpers/pair-device.ts
index d8908015b8..46cdda5ed9 100644
--- a/packages/app/e2e/support/helpers/pair-device.ts
+++ b/packages/app/e2e/support/helpers/pair-device.ts
@@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged(
expect(before).toBeDefined();
expect(after).toBe(before);
}
+
+export async function expectCompletePairingQr(page: Page): Promise {
+ const qr = page.getByRole("img", { name: "Pairing QR code" });
+ await qr.scrollIntoViewIfNeeded();
+ // IntersectionObserver rounds fractional sheet/scroll coordinates.
+ await expect(qr).toBeInViewport({ ratio: 0.999 });
+ const bounds = await qr.evaluate((element) => {
+ const tile = element.parentElement;
+ if (!tile) throw new Error("Pairing QR tile is missing");
+ const qrRect = element.getBoundingClientRect();
+ const tileRect = tile.getBoundingClientRect();
+ return {
+ qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
+ tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
+ };
+ });
+ expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
+ expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
+ expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
+ expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
+}
diff --git a/packages/app/e2e/support/helpers/plugin-button-example.ts b/packages/app/e2e/support/helpers/plugin-button-example.ts
index 94018c4415..ebe868ee2c 100644
--- a/packages/app/e2e/support/helpers/plugin-button-example.ts
+++ b/packages/app/e2e/support/helpers/plugin-button-example.ts
@@ -30,7 +30,6 @@ async function capture(
name: string,
subject: Locator = headerButtons(page),
) {
- await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
+ await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
- await capture(page, info, "02-compact-actions");
+ await expect(headerButtons(page)).toHaveCount(0);
+ await capture(
+ page,
+ info,
+ "02-compact-actions",
+ page.getByRole("button", { name: "Refresh context", exact: true }),
+ );
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
- await headerButtons(page).click();
+ await expect(headerButtons(page)).toHaveCount(0);
+ await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
+ await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts
index 7404173fc8..f3279ce7ef 100644
--- a/packages/app/e2e/support/helpers/plugin-buttons.ts
+++ b/packages/app/e2e/support/helpers/plugin-buttons.ts
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
-async function holdButton(page: Page, button: Locator) {
- await button.hover();
- await page.mouse.down();
-}
-
-async function openCompactOverflowWithHeaderChrome(page: Page) {
- const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
- const action = page.getByRole("button", { name: "Deploy application", exact: true });
- const overflow = page
- .getByRole("toolbar", { name: "Workspace actions", exact: true })
- .getByRole("button", { name: "More actions", exact: true });
- await expect(action).toHaveCSS("border-top-width", "0px");
- await expect(overflow).toHaveCSS("border-top-width", "0px");
- await hamburger.hover();
- const highlight = await hamburger.evaluate(
- (element) => getComputedStyle(element).backgroundColor,
- );
- await action.hover();
- await expect(action).toHaveCSS("background-color", highlight);
- await holdButton(page, overflow);
- await expect(overflow).toHaveCSS("background-color", highlight);
- await expectSurfaceVisible(page);
- await page.mouse.up();
-}
-
export async function withButtonShowcase(
page: Page,
run: (buttons: {
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
- test.step("compact triggers and overflow open sheets with working plugin context", async () => {
+ test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
- await expectSurfaceVisible(page);
- await openCompactOverflowWithHeaderChrome(page);
await expect(
- page.getByRole("menuitem", { name: "Production locked", exact: true }),
- ).toBeDisabled();
- await expectSurfaceVisible(
- page,
- page.getByRole("menuitem", { name: "Production locked", exact: true }),
- );
- await choose(page, "Header checks");
- await expectSurfaceVisible(
- page,
- page.getByRole("menuitem", { name: "Run checks", exact: true }),
- );
- await choose(page, "Deployment details");
- await expectDetails(page);
- await expectSurfaceVisible(
- page,
- page.getByRole("button", { name: "Close deployment details", exact: true }),
- );
- await press(page, "Close deployment details");
- await showcase.setTitle("Compact checks");
- await press(page, "Header checks");
- await expect(
- page
- .getByRole("button", { name: "Header checks", exact: true })
- .getByTestId("plugin-button-chevron"),
+ page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
- await expectSurfaceVisible(
- page,
- page.getByRole("menuitem", { name: "Run checks", exact: true }),
- );
- await choose(page, "Run checks");
+ await expect(
+ page.getByRole("button", { name: "Open menu", exact: true }),
+ ).toBeInViewport();
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts
index d6656f3d3a..8b0f680e2d 100644
--- a/packages/app/e2e/support/helpers/plugin-timeline.ts
+++ b/packages/app/e2e/support/helpers/plugin-timeline.ts
@@ -47,6 +47,65 @@ export default function contribute(client) {
return () => {};
}`;
+const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
+
+function promptClientSource(prompts?: readonly string[]): string {
+ return `import React from "react";
+import { Text, View } from "react-native";
+import { z } from "zod";
+
+const selectedPrompts = ${JSON.stringify(prompts ?? null)};
+
+function PromptCard({ item }) {
+ return {item.data.text} ;
+}
+
+export default function contribute(client) {
+ client.addTimelineTransformer({
+ id: "prompt",
+ query: { itemType: "user_message" },
+ transform({ item }) {
+ if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
+ return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
+ },
+ });
+ client.addTimelineRenderer({
+ kind: "prompt", version: 1,
+ schema: z.object({ text: z.string() }),
+ Component: PromptCard,
+ });
+ return () => {};
+}`;
+}
+
+async function withDirectoryPlugin(options: {
+ pluginId: string;
+ source: string;
+ run: () => Promise;
+}): Promise {
+ const { pluginId, source, run } = options;
+ const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
+ const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const previous = await pluginClient.getDaemonConfig();
+ try {
+ await writeFile(
+ path.join(directory, "paseo-plugin.json"),
+ JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
+ );
+ await writeFile(path.join(directory, "index.client.tsx"), source);
+ await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
+ await pluginClient.installDirectoryPlugin(directory);
+ await run();
+ } finally {
+ await pluginClient.removePlugin(pluginId);
+ await pluginClient.patchDaemonConfig({
+ pluginsEnabled: previous.config.pluginsEnabled ?? false,
+ });
+ await pluginClient.close();
+ await rm(directory, { recursive: true, force: true });
+ }
+}
+
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
@@ -60,7 +119,6 @@ export async function withTimelinePlugin(
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
- const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
@@ -73,40 +131,53 @@ export async function withTimelinePlugin(
}
: {},
});
- const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
- const previous = await pluginClient.getDaemonConfig();
try {
- await writeFile(
- path.join(directory, "paseo-plugin.json"),
- JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
- );
- await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
- await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
- await pluginClient.installDirectoryPlugin(directory);
- await openAgentRoute(page, agent);
- await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
- await run(agent);
- await info.attach("timeline-plugin", {
- body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
- contentType: "image/png",
+ await withDirectoryPlugin({
+ pluginId: PLUGIN_ID,
+ source: CLIENT_SOURCE,
+ run: async () => {
+ try {
+ await openAgentRoute(page, agent);
+ await expect(
+ page.getByRole("textbox", { name: "Message agent..." }).first(),
+ ).toBeVisible();
+ await run(agent);
+ await info.attach("timeline-plugin", {
+ body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
+ contentType: "image/png",
+ });
+ } catch (error) {
+ await info.attach("timeline-before-cleanup", {
+ body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
+ contentType: "image/png",
+ });
+ throw error;
+ }
+ },
});
- } catch (error) {
- await info.attach("timeline-before-cleanup", {
- body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
- contentType: "image/png",
- });
- throw error;
} finally {
- await pluginClient.removePlugin(PLUGIN_ID);
- await pluginClient.patchDaemonConfig({
- pluginsEnabled: previous.config.pluginsEnabled ?? false,
- });
- await pluginClient.close();
await agent.cleanup();
- await rm(directory, { recursive: true, force: true });
}
}
+/** Draws all prompts, or only the selected prompts, as plugin cards. */
+export async function withPromptTimelinePlugin(
+ run: () => Promise,
+ prompts?: readonly string[],
+): Promise {
+ await withDirectoryPlugin({
+ pluginId: PROMPT_PLUGIN_ID,
+ source: promptClientSource(prompts),
+ run,
+ });
+}
+
+export async function expectPromptsDrawnByPlugin(page: Page): Promise {
+ const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
+ await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
+ await expect(timeline.getByTestId("user-message")).toHaveCount(0);
+}
+
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
@@ -142,3 +213,64 @@ export async function expectBothConsecutiveTools(page: Page): Promise {
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
+
+/** The row belongs to the virtualized history, not the always-mounted recent window. */
+export async function expectVirtualizedPluginPromptVisible(
+ page: Page,
+ prompt: string,
+): Promise {
+ const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
+ await expect(
+ timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
+ ).toBeVisible();
+}
+
+export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise {
+ await info.attach("plugin-outline-jump", {
+ body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
+ contentType: "image/png",
+ });
+}
+
+const POPOVER_CLIENT_SOURCE = `import React, { useEffect, useRef } from "react";
+import { z } from "zod";
+
+function Preview() {
+ const panel = useRef(null);
+ useEffect(() => {
+ const close = (event) => {
+ if (event.key === "Escape" && panel.current.matches(":popover-open")) {
+ panel.current.hidePopover();
+ }
+ };
+ document.addEventListener("keydown", close, true);
+ return () => document.removeEventListener("keydown", close, true);
+ }, []);
+ return
+
panel.current.showPopover()}>Hover plugin preview
+
Plugin hover preview
+
;
+}
+
+export default function contribute(client) {
+ client.addTimelineTransformer({
+ id: "preview", query: { itemType: "user_message" },
+ transform({ item }) {
+ if (item.text !== "Show the plugin preview.") return;
+ return { items: [{ type: "plugin", kind: "preview", version: 1, data: {} }] };
+ },
+ });
+ client.addTimelineRenderer({
+ kind: "preview", version: 1, schema: z.object({}), Component: Preview,
+ });
+ return () => {};
+}`;
+
+/** Installs a timeline preview that owns Escape through the DOM Popover API. */
+export async function withPopoverTimelinePlugin(run: () => Promise): Promise {
+ await withDirectoryPlugin({
+ pluginId: "timeline-popover-e2e",
+ source: POPOVER_CLIENT_SOURCE,
+ run,
+ });
+}
diff --git a/packages/app/e2e/support/helpers/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts
index 56f0ef444c..37fe719e0f 100644
--- a/packages/app/e2e/support/helpers/pr-pane.ts
+++ b/packages/app/e2e/support/helpers/pr-pane.ts
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
-import { getStateLabel } from "@/git/pull-request-panel/data";
+import { en } from "@/i18n/resources/en";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise {
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise {
- await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
+ await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
timeout: 15_000,
});
}
diff --git a/packages/app/e2e/support/helpers/questions.ts b/packages/app/e2e/support/helpers/questions.ts
index ebe13aead5..a26bd9a975 100644
--- a/packages/app/e2e/support/helpers/questions.ts
+++ b/packages/app/e2e/support/helpers/questions.ts
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
}
+export async function expectQuestionOptionPreview(
+ page: Page,
+ input: { option: string; preview: string },
+): Promise {
+ await expect(
+ questionOption(page, input.option).getByTestId("question-form-option-preview"),
+ ).toContainText(input.preview);
+}
+
export async function openQuestion(
page: Page,
input: { index: number; total: number },
diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts
index 869e96b294..4aec3fff01 100644
--- a/packages/app/e2e/support/helpers/seed-client.ts
+++ b/packages/app/e2e/support/helpers/seed-client.ts
@@ -66,6 +66,7 @@ export interface SeedDaemonClient {
worktreeSlug?: string;
};
title?: string;
+ background?: boolean;
}): Promise<{
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
@@ -223,6 +224,7 @@ export interface SeededWorkspace {
export async function seedWorkspace(options: {
repoPrefix: string;
title?: string;
+ background?: boolean;
port?: number;
/** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters[1];
@@ -238,6 +240,7 @@ export async function seedWorkspace(options: {
const created = await client.createWorkspace({
source: { kind: "directory", path: project.path },
title: options.title,
+ background: options.background,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts
index 1702f7fe44..6b810e6d13 100644
--- a/packages/app/e2e/support/helpers/settings.ts
+++ b/packages/app/e2e/support/helpers/settings.ts
@@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise {
await findAcpCatalogProvider(page, providerName);
- await page.getByRole("button", { name: "Add", exact: true }).click();
+ await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
}
export async function expectProviderInstalledInSettings(
diff --git a/packages/app/e2e/support/helpers/sheet.ts b/packages/app/e2e/support/helpers/sheet.ts
new file mode 100644
index 0000000000..6288c6f9c1
--- /dev/null
+++ b/packages/app/e2e/support/helpers/sheet.ts
@@ -0,0 +1,99 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/** A sheet tab's body, named after the tab it shows. */
+export function sheet(page: Page, label: string): Locator {
+ return page.getByRole("dialog", { name: label });
+}
+
+/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
+export function sheetHeader(page: Page, label: string): Locator {
+ return page.getByTestId("sheet-tab-header").filter({ hasText: label });
+}
+
+/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
+const SHEET_SNAP_FRACTION = 0.92;
+/** The header starts below the sheet's drag indicator, inside the handle. */
+const SHEET_INDICATOR_MAX_HEIGHT = 32;
+
+/**
+ * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
+ * covers the workspace header; a sheet sunk below it hides content.
+ */
+export async function expectSheetAtSnap(page: Page, label: string): Promise {
+ const viewportHeight = page.viewportSize()?.height ?? 0;
+ const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
+ const header = sheetHeader(page, label);
+ const body = sheet(page, label);
+ await expect
+ .poll(async () => {
+ const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
+ if (!headerBox || !bodyBox) return null;
+ const top = Math.round(headerBox.y);
+ return {
+ dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
+ topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
+ };
+ })
+ .toEqual({ dockedToBottom: true, topAtSnap: true });
+}
+
+export async function expectOpenTabCount(page: Page, count: number): Promise {
+ await expect(
+ page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
+ ).toBeVisible();
+}
+
+export async function closeSheet(page: Page, label: string): Promise {
+ await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
+ await expect(sheet(page, label)).toHaveCount(0);
+}
+
+export async function openSheetAsTab(page: Page, label: string): Promise {
+ await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
+ await expect(sheet(page, label)).toHaveCount(0);
+}
+
+export async function replaceSheetFileText(
+ page: Page,
+ label: string,
+ content: string,
+): Promise {
+ const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
+ await editor.click();
+ await editor.press("ControlOrMeta+A");
+ await editor.pressSequentially(content);
+}
+
+export async function selectTabFromSwitcher(page: Page, label: string): Promise {
+ await page
+ .getByRole("button", { name: /Switch tabs/ })
+ .filter({ visible: true })
+ .click();
+ await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
+ const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
+ await expect(option).toBeVisible();
+ await option.click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
+/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
+export async function tapSheetBackdrop(page: Page): Promise {
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .last()
+ .click({ position: { x: 10, y: 10 } });
+}
+
+/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
+export async function expectDeclinedDiscardPrompt(
+ page: Page,
+ close: () => Promise,
+): Promise {
+ const prompt = page.waitForEvent("dialog").then(async (dialog) => {
+ const message = dialog.message();
+ await dialog.dismiss();
+ return message;
+ });
+ await close();
+ expect(await prompt).toContain("discard the draft");
+}
diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
index b707abdae8..e3eff916e7 100644
--- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
+++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise {
- const boxes = (
- await Promise.all(
- FOOTER_ICON_TEST_IDS.map((testID) =>
- page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
- ),
- )
- ).map((box) => box!);
+ // Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
+ const boxes = await page
+ .locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
+ .evaluateAll((elements) =>
+ elements.map((element) => {
+ const { x, y, width, height } = element.getBoundingClientRect();
+ return { testID: element.getAttribute("data-testid"), x, y, width, height };
+ }),
+ );
+ expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts
index 1909b4f31b..500ce8bd41 100644
--- a/packages/app/e2e/support/helpers/sidebar.ts
+++ b/packages/app/e2e/support/helpers/sidebar.ts
@@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise
export async function expectMobileAgentSidebarHidden(page: Page): Promise {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
+
+/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
+export async function swipeOpenMobileAgentSidebar(page: Page): Promise {
+ const viewport = page.viewportSize();
+ if (!viewport) throw new Error("viewport size is required");
+ const y = Math.round(viewport.height / 2);
+ await page.mouse.move(8, y);
+ await page.mouse.down();
+ await page.mouse.move(40, y, { steps: 4 });
+ await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
+ await page.mouse.up();
+}
+
+/** Drag from the right edge to open the compact Explorer over the agent. */
+export async function swipeOpenMobileExplorer(page: Page): Promise {
+ const viewport = page.viewportSize();
+ if (!viewport) throw new Error("viewport size is required");
+ const y = Math.round(viewport.height / 2);
+ await page.mouse.move(viewport.width - 8, y);
+ await page.mouse.down();
+ await page.mouse.move(viewport.width - 40, y, { steps: 4 });
+ await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 });
+ await page.mouse.up();
+}
+
+export async function closeMobileExplorer(page: Page): Promise {
+ const closeButton = page.getByTestId("explorer-close");
+ await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
+ await closeButton.click();
+}
+
+export async function expectMobileExplorerVisible(page: Page): Promise {
+ await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
+}
+
+export async function expectMobileExplorerHidden(page: Page): Promise {
+ await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
+}
diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts
index d9c59e702e..f3a5ca7c71 100644
--- a/packages/app/e2e/support/helpers/subagents.ts
+++ b/packages/app/e2e/support/helpers/subagents.ts
@@ -49,31 +49,37 @@ export async function seedParentWithSubagent(
modeId: "load-test",
model: "ten-second-stream",
});
- const child = await workspace.client.createAgent({
- provider: "mock",
- cwd: workspace.repoPath,
- workspaceId: workspace.workspaceId,
- title: input.childTitle,
- modeId: "load-test",
- model: "ten-second-stream",
- labels: {
- [PARENT_AGENT_ID_LABEL]: parent.id,
- },
- });
+ const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
return {
parent: {
id: parent.id,
title: input.parentTitle,
},
- child: {
- id: child.id,
- title: input.childTitle,
- },
+ child,
workspaceId: workspace.workspaceId,
};
}
+/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
+export async function seedSubagent(
+ workspace: Pick,
+ input: { parentId: string; title: string },
+): Promise<{ id: string; title: string }> {
+ const child = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: input.title,
+ modeId: "load-test",
+ model: "ten-second-stream",
+ labels: {
+ [PARENT_AGENT_ID_LABEL]: input.parentId,
+ },
+ });
+ return { id: child.id, title: input.title };
+}
+
export async function seedParentWithCrossWorkspaceSubagent(
workspace: Pick,
input: { parentTitle: string; childTitle: string },
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise {
await expect(panel).toBeVisible({ timeout: 30_000 });
}
+/** On compact the track opens as a sheet, which closes once a row is chosen. */
+export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise {
+ await page.getByTestId("subagents-track-header").click();
+ await page.getByTestId(`subagents-track-row-${childId}`).click();
+}
+
export async function expectSubagentRowVisible(page: Page, childId: string): Promise {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
diff --git a/packages/app/e2e/support/helpers/terminal-dsl.ts b/packages/app/e2e/support/helpers/terminal-dsl.ts
index 4fff26095d..ff8bf6cf0b 100644
--- a/packages/app/e2e/support/helpers/terminal-dsl.ts
+++ b/packages/app/e2e/support/helpers/terminal-dsl.ts
@@ -172,8 +172,9 @@ export class TerminalE2EHarness {
}
async closeOverlayAndFocusButtonBeforeRetry(page: Page): Promise {
- await page.clock.install();
- await page.clock.pauseAt(new Date());
+ // Give the browser clock a fixed origin before pausing it in the future.
+ await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
+ await page.clock.pauseAt(new Date("2026-01-01T00:01:00Z"));
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await page.getByTestId("sidebar-search").focus();
diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts
index 7dd3dca8d7..52027df956 100644
--- a/packages/app/e2e/support/helpers/timeline-pagination.ts
+++ b/packages/app/e2e/support/helpers/timeline-pagination.ts
@@ -544,7 +544,7 @@ async function readTimelineViewport(page: Page): Promise {
+export async function waitForTimelineGeometryToSettle(page: Page): Promise {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate(
(element) =>
diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts
index 01055ce2c1..f7b62fd953 100644
--- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts
+++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts
@@ -297,11 +297,7 @@ export function findScrollJumps(frames: ScrollFrame[]) {
// Follow that intended row, not text now below the image.
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
- const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
- const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
- const readingLine = 8 - availableScroll;
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
- const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
const anchor = previous.anchor;
const before = currentRows.has(anchor)
? previous.rows.find((row) => row.id === anchor)!
@@ -316,25 +312,33 @@ export function findScrollJumps(frames: ScrollFrame[]) {
// A busy main thread can deliver wheel movement hundreds of milliseconds
// after its event. Only assert idle stability after the driver stops input.
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
- // Wheel events can precede their scroll update, or include input already
- // applied in earlier frames. Locate the reader with this frame's scroll,
- // bounded by recent input. Growth below it can move the old anchor without
- // moving the reading line.
- const readerFollowsInput =
- intendedRow &&
- currentRows.has(intendedRow.id) &&
- Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
- const enteredRowGrowth = readerFollowsInput
+ // scrollTop also includes virtualizer estimate corrections. Locate the row
+ // entered by wheel input from its viewport movement instead. Its old height
+ // identifies the reader before a simultaneous image resize takes effect.
+ const intendedRow = previous.rows.find((row) => {
+ const next = currentRows.get(row.id);
+ if (!next || idle || wheelBudget <= 0) return false;
+ const travel = next.top - row.top;
+ return (
+ travel >= -8 &&
+ travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
+ (next.top <= 8 || current.scrollTop === 0) &&
+ next.top + row.height > 8
+ );
+ });
+ const enteredRowResize = intendedRow
? previous.rows.reduce((growth, row) => {
if (row.top < intendedRow.top || row.top >= before.top) return growth;
const height = currentRows.get(row.id)?.height ?? row.height;
- return growth + Math.max(0, height - row.height);
+ return growth + height - row.height;
}, 0)
: 0;
- const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
+ const readingMovement = movement - enteredRowResize;
+ const excessForward = readingMovement > wheelBudget + 32;
// Upward wheel input moves the same text DOWN the viewport. A negative move
// is a reversal, independent of legitimate scrollTop compensation on prepend.
- if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
+ if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
+ return [];
return [
{
frame: index,
diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts
index 985e7bd7ac..6129f0259e 100644
--- a/packages/app/e2e/support/helpers/usage-login.ts
+++ b/packages/app/e2e/support/helpers/usage-login.ts
@@ -58,10 +58,6 @@ export default function contribute(server) {
}
}
-export async function openUsage(page: Page) {
- await page.goto("/usage");
-}
-
export async function refreshLoginUsage(page: Page) {
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
}
diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts
index d21473867e..95c6a4d689 100644
--- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts
+++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts
@@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
-/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
+/** Real usage-source plugin; its long report makes the Usage modal scroll. */
export async function installTallUsageSource() {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
@@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
-/** The compact usage sheet the Usage item opens. */
-export function usageSheet(page: Page): Locator {
- return visible(page, "usage-expanded");
-}
-
-export async function expectOnUsageScreen(page: Page): Promise {
- await expect(page).toHaveURL(/\/usage$/);
+/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */
+export function usageModal(page: Page): Locator {
+ return visible(page, "usage-modal-body");
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
@@ -189,23 +185,53 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
-/** The footer's Usage icon, which is there whether or not the Usage item is on. */
-export async function openUsageScreenFromIcon(page: Page): Promise {
+/**
+ * The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the
+ * sidebar drawer has to be open.
+ */
+export async function openUsageFromIcon(page: Page): Promise {
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
- await expectOnUsageScreen(page);
+ await expect(usageModal(page)).toBeVisible();
}
-/** Both footer entry points open Usage over the current screen on a phone. */
-export async function openUsageSheetFromIcon(page: Page): Promise {
- await page.getByRole("button", { name: "Usage", exact: true }).click();
- await expect(usageSheet(page)).toBeVisible();
+/** The footer's Usage item, its summary of pinned windows. */
+export async function openUsageFromItem(page: Page): Promise {
+ await usageItem(page).click();
+ await expect(usageModal(page)).toBeVisible();
}
-export async function closeUsageSheet(page: Page): Promise {
- const sheet = usageSheet(page);
+/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
+export async function scrollUsage(page: Page, deltaY: number): Promise {
+ const body = await usageModal(page).boundingBox();
+ const viewport = page.viewportSize();
+ if (!body || !viewport) throw new Error("Usage must be open before scrolling it.");
+ // The middle of the part of the body on screen; the body itself runs past the viewport.
+ const top = Math.max(body.y, 0);
+ const bottom = Math.min(body.y + body.height, viewport.height);
+ await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2);
+ await page.mouse.wheel(0, deltaY);
+}
+
+/** Reach a window with the wheel even when other reports follow its card. */
+export async function scrollUsageToWindow(page: Page, label: string): Promise {
+ const target = await usageModal(page).getByText(label, { exact: true }).boundingBox();
+ const viewport = page.viewportSize();
+ if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it.");
+ await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2);
+}
+
+/** The modal's own Close button. */
+export async function closeUsage(page: Page): Promise {
+ await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
+ await expect(usageModal(page)).toHaveCount(0);
+}
+
+/** A tap on the backdrop above the bottom sheet. */
+export async function closeUsageFromBackdrop(page: Page): Promise {
+ const sheet = usageModal(page);
await waitForSettledPosition(sheet);
- const bounds = await sheet.boundingBox();
- if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
+ const bounds = await visible(page, "usage-modal").boundingBox();
+ if (!bounds) throw new Error("Usage must be open before closing it.");
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
await expect(sheet).toHaveCount(0);
}
@@ -214,7 +240,7 @@ function summaryInSidebarSwitch(page: Page): Locator {
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
}
-/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
+/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */
export async function setSummaryInSidebar(page: Page, on: boolean): Promise {
await openUsageOptions(page);
await summaryInSidebarSwitch(page).click();
@@ -275,9 +301,3 @@ export async function refreshAllUsage(page: Page): Promise {
export async function openCompactSidebar(page: Page): Promise {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
-
-/** On a phone the Usage screen has a back header instead of the sidebar menu. */
-export async function leaveUsageScreen(page: Page): Promise {
- await page.getByRole("button", { name: "Back", exact: true }).first().click();
- await expect(page).not.toHaveURL(/\/usage$/);
-}
diff --git a/packages/app/index.ts b/packages/app/index.ts
index 03b518fd2c..82d47bfe8c 100644
--- a/packages/app/index.ts
+++ b/packages/app/index.ts
@@ -1,10 +1,6 @@
-// Polyfill crypto.randomUUID for React Native before any other imports
-import { polyfillCrypto } from "./src/polyfills/crypto";
-polyfillCrypto();
-
-// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
-import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
-polyfillScreenOrientation();
+// Install runtime polyfills before any other module evaluates. Keep this the first import.
+// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
+import "./src/polyfills/install-early";
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt
index 7d6fcee8ed..c7007776ba 100644
--- a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt
+++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt
@@ -9,7 +9,11 @@ import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
+import com.facebook.react.bridge.JavaOnlyArray
+import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
+import com.facebook.react.uimanager.DisplayMetricsHolder
+import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
@@ -47,16 +51,29 @@ class ScrollSelectionTest {
assertNotEquals(before, scroll.scrollY)
}
- private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
+ @Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text ->
+ val before = scroll.scrollY
+ assertTrue(text.requestFocus())
+ text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
+ assertEquals(before, scroll.scrollY)
+ }
+
+ @Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text ->
+ val before = scroll.scrollY
+ text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
+ assertNotEquals(before, scroll.scrollY)
+ }
+
+ private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
- scroll.scaleY = if (inverted) -1f else 1f
val content = FrameLayout(context)
val text = TextView(context).apply {
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
@@ -68,6 +85,13 @@ class ScrollSelectionTest {
content.layout(0, 0, 300, 2000)
scroll.measure(exact(300), exact(300))
scroll.layout(0, 0, 300, 300)
+ // Apply RN's actual Android inversion through the manager's transform decomposition.
+ val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0))
+ manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms)))
+ if (inverted && transform == "scale") {
+ assertEquals(1f, scroll.scaleY, 0f)
+ assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f)
+ }
scroll.scrollTo(0, 400)
check(scroll, text)
}
diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt
index c3a2a4373d..71ff179cb1 100644
--- a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt
+++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt
@@ -1,5 +1,6 @@
package sh.paseo.scroll
+import android.graphics.Matrix
import android.graphics.Rect
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
@@ -27,9 +28,15 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
}
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
+ private val transformValues = FloatArray(9)
+
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
- override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
- if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
+ override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
+ // RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
+ matrix.getValues(transformValues)
+ if (transformValues[Matrix.MSCALE_Y] < 0) return 0
+ return super.computeScrollDeltaToGetChildRectOnScreen(rect)
+ }
}
diff --git a/packages/app/package.json b/packages/app/package.json
index f23f06ce52..84af04dee4 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
- "version": "0.11.0-beta.5",
+ "version": "0.11.2",
"private": true,
"main": "index.ts",
"scripts": {
diff --git a/packages/app/plugins/with-android-optional-camera.js b/packages/app/plugins/with-android-optional-camera.js
new file mode 100644
index 0000000000..dd5776e155
--- /dev/null
+++ b/packages/app/plugins/with-android-optional-camera.js
@@ -0,0 +1,24 @@
+const { withAndroidManifest } = require("expo/config-plugins");
+
+// Requesting CAMERA implies these features are required, which hides the app
+// on Google Play for devices without a camera. Pairing works without QR scanning.
+const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"];
+
+function withAndroidOptionalCamera(config) {
+ return withAndroidManifest(config, (modConfig) => {
+ const manifest = modConfig.modResults.manifest;
+ const features = manifest["uses-feature"] ?? [];
+ for (const name of OPTIONAL_CAMERA_FEATURES) {
+ const existing = features.find((feature) => feature.$["android:name"] === name);
+ if (existing) {
+ existing.$["android:required"] = "false";
+ } else {
+ features.push({ $: { "android:name": name, "android:required": "false" } });
+ }
+ }
+ manifest["uses-feature"] = features;
+ return modConfig;
+ });
+}
+
+module.exports = withAndroidOptionalCamera;
diff --git a/packages/app/src/agent-controls/labels.test.ts b/packages/app/src/agent-controls/labels.test.ts
index 4f1302fd1b..7310464896 100644
--- a/packages/app/src/agent-controls/labels.test.ts
+++ b/packages/app/src/agent-controls/labels.test.ts
@@ -29,4 +29,15 @@ describe("formatThinkingOptionLabel", () => {
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
});
+
+ it("preserves provider-supplied xhigh labels with extra detail", () => {
+ expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe(
+ "X high (56 | 8m)",
+ );
+ expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe(
+ "X high (45 | 6m)",
+ );
+ expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high");
+ expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high");
+ });
});
diff --git a/packages/app/src/agent-controls/labels.ts b/packages/app/src/agent-controls/labels.ts
index ff32aaf4dc..704298f78c 100644
--- a/packages/app/src/agent-controls/labels.ts
+++ b/packages/app/src/agent-controls/labels.ts
@@ -34,10 +34,9 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string {
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const rawLabel = (option.label ?? option.id).trim();
- const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
- if (compactId === "xhigh" || compactLabel === "xhigh") {
+ if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
return i18n.t("agentControls.thinking.extraHigh");
}
diff --git a/packages/app/src/agent-stream/chat-find/types.ts b/packages/app/src/agent-stream/chat-find/types.ts
index cce58be96e..4fdf543a5e 100644
--- a/packages/app/src/agent-stream/chat-find/types.ts
+++ b/packages/app/src/agent-stream/chat-find/types.ts
@@ -2,6 +2,9 @@ import type { ReactNode, RefObject } from "react";
import type { StreamItem } from "@/types/stream";
import type { StreamViewportHandle } from "../strategy";
+export interface MountedMessageIds {
+ has(messageId: string): boolean;
+}
export interface ChatFindProps {
agentId: string;
serverId: string;
@@ -9,7 +12,7 @@ export interface ChatFindProps {
items: StreamItem[];
viewportRef: RefObject;
revealLoadedMessage(messageId: string): boolean;
- visibleMessageIds: ReadonlySet;
+ visibleMessageIds: MountedMessageIds;
children: ReactNode;
}
export interface ChatFindExpansionProps {
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 4b2139fef6..bd5a73ce94 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
@@ -178,7 +178,7 @@ describe("useChatOutline", () => {
timelineEpoch,
enabled,
viewportRef,
- visibleMessageIds: new Set(),
+ mountedHistoryMessageIds: new Set(),
onJumpError: vi.fn(),
}),
{
@@ -278,7 +278,7 @@ describe("useChatOutline", () => {
},
];
const { result, rerender } = renderHook(
- ({ visibleMessageIds }) =>
+ ({ mountedHistoryMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -288,10 +288,10 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
- visibleMessageIds,
+ mountedHistoryMessageIds,
revealLoadedMessage,
}),
- { initialProps: { visibleMessageIds: new Set() } },
+ { initialProps: { mountedHistoryMessageIds: new Set() } },
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
@@ -299,7 +299,7 @@ describe("useChatOutline", () => {
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
expect(scrollToMessage).not.toHaveBeenCalled();
- rerender({ visibleMessageIds: new Set(["older-prompt"]) });
+ rerender({ mountedHistoryMessageIds: new Set(["older-prompt"]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
});
@@ -327,7 +327,7 @@ describe("useChatOutline", () => {
timelineCursor: { epoch: "epoch-1", seq: 1 },
};
const { result, rerender } = renderHook(
- ({ tail, visibleMessageIds }) =>
+ ({ tail, mountedHistoryMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -337,13 +337,13 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
- visibleMessageIds,
+ mountedHistoryMessageIds,
revealLoadedMessage,
}),
{
initialProps: {
tail: [] as (typeof fetchedPrompt)[],
- visibleMessageIds: new Set(),
+ mountedHistoryMessageIds: new Set(),
},
},
);
@@ -352,11 +352,11 @@ describe("useChatOutline", () => {
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
- rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set() });
+ rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set() });
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
expect(scrollToMessage).not.toHaveBeenCalled();
- rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
+ rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set([fetchedPrompt.id]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
await act(async () => fetch.resolve());
@@ -392,7 +392,7 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef: { current: viewport },
onJumpError: vi.fn(),
- visibleMessageIds: new Set([livePrompt.id]),
+ mountedHistoryMessageIds: new Set(),
revealLoadedMessage,
}),
);
diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
index 015b883d21..cfdb26b2a5 100644
--- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
+++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
+import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -33,7 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject;
onJumpError: () => void;
- visibleMessageIds?: ReadonlySet;
+ // Message ids of the history the viewport has mounted. Live head messages are always mounted.
+ mountedHistoryMessageIds?: ReadonlySet;
revealLoadedMessage?: (messageId: string) => boolean;
}
@@ -44,6 +45,24 @@ export interface ChatOutline {
reportReadingPosition: (seq: number | null) => void;
}
+function latestPromptSeqIn(items: StreamItem[], timelineEpoch: string | null): number {
+ let latest = -1;
+ for (const item of items) {
+ if (item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch) {
+ latest = Math.max(latest, item.timelineCursor.seq);
+ }
+ }
+ return latest;
+}
+
+function findLoadedItem(
+ tail: StreamItem[],
+ head: StreamItem[],
+ predicate: (item: StreamItem) => boolean,
+): StreamItem | undefined {
+ return tail.find(predicate) ?? head.find(predicate);
+}
+
export function useChatOutline({
agentId,
serverId,
@@ -53,7 +72,7 @@ export function useChatOutline({
enabled,
viewportRef,
onJumpError,
- visibleMessageIds,
+ mountedHistoryMessageIds,
revealLoadedMessage,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState(null);
@@ -62,18 +81,19 @@ export function useChatOutline({
const readingSeqRef = useRef(null);
const nextJumpRequestIdRef = useRef(0);
const nextIndexRequestIdRef = useRef(0);
- const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
+ // A disabled outline (every native viewport) must not pay for the timeline per delta.
+ const loadedTail = enabled ? tail : NO_STREAM_ITEMS;
+ const loadedHead = enabled ? (head ?? NO_STREAM_ITEMS) : NO_STREAM_ITEMS;
const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS;
// The viewed timeline already owns live delivery and reconnect catch-up. Its complete
// loaded items (including rows outside the mounted window) invalidate the prompt index.
- const latestPromptSeq = loadedItems.reduce(
- (latest, item) =>
- item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch
- ? Math.max(latest, item.timelineCursor.seq)
- : latest,
- -1,
+ // Only the head changes per streamed delta, so the tail's contribution is memoized.
+ const tailPromptSeq = useMemo(
+ () => latestPromptSeqIn(loadedTail, timelineEpoch),
+ [loadedTail, timelineEpoch],
);
+ const latestPromptSeq = Math.max(tailPromptSeq, latestPromptSeqIn(loadedHead, timelineEpoch));
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
@@ -135,10 +155,18 @@ export function useChatOutline({
useEffect(() => {
if (pendingJump === null) return;
- const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
+ const target = findLoadedItem(
+ loadedTail,
+ loadedHead,
+ (item) => item.timelineCursor?.seq === pendingJump.seq,
+ );
if (target) {
if (pendingJump.hasScrolled) return;
- if (visibleMessageIds?.has(target.id) === false) {
+ const isMounted =
+ mountedHistoryMessageIds === undefined ||
+ mountedHistoryMessageIds.has(target.id) ||
+ loadedHead.includes(target);
+ if (!isMounted) {
revealLoadedMessage?.(target.id);
return;
}
@@ -150,40 +178,48 @@ export function useChatOutline({
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
- }, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
+ }, [
+ loadedHead,
+ loadedTail,
+ mountedHistoryMessageIds,
+ pendingJump,
+ revealLoadedMessage,
+ viewportRef,
+ ]);
- const jumpToPrompt = useCallback(
- (seq: number) => {
- nextJumpRequestIdRef.current += 1;
- setPendingJump(null);
- const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
- if (loaded) {
- if (revealLoadedMessage?.(loaded.id)) {
- const requestId = nextJumpRequestIdRef.current;
- setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
- return;
- }
- viewportRef.current?.scrollToMessage?.(loaded.id);
+ const jumpToPrompt = useStableEvent((seq: number) => {
+ nextJumpRequestIdRef.current += 1;
+ setPendingJump(null);
+ const loaded = findLoadedItem(
+ loadedTail,
+ loadedHead,
+ (item) => item.timelineCursor?.seq === seq,
+ );
+ if (loaded) {
+ if (revealLoadedMessage?.(loaded.id)) {
+ const requestId = nextJumpRequestIdRef.current;
+ setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
}
- if (!index) return;
- const requestId = nextJumpRequestIdRef.current;
- setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
- void getHostRuntimeStore()
- .fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
- .catch((error: unknown) => {
- console.warn("Failed to load a Chat outline window", error);
- onJumpError();
- })
- .finally(() => {
- setPendingJump((current) => {
- if (current?.requestId !== requestId) return current;
- return { ...current, fetchSettled: true };
- });
+ viewportRef.current?.scrollToMessage?.(loaded.id);
+ return;
+ }
+ if (!index) return;
+ const requestId = nextJumpRequestIdRef.current;
+ setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
+ void getHostRuntimeStore()
+ .fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
+ .catch((error: unknown) => {
+ console.warn("Failed to load a Chat outline window", error);
+ onJumpError();
+ })
+ .finally(() => {
+ setPendingJump((current) => {
+ if (current?.requestId !== requestId) return current;
+ return { ...current, fetchSettled: true };
});
- },
- [agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
- );
+ });
+ });
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
}
diff --git a/packages/app/src/agent-stream/model-retention.test.ts b/packages/app/src/agent-stream/model-retention.test.ts
new file mode 100644
index 0000000000..c82b29d301
--- /dev/null
+++ b/packages/app/src/agent-stream/model-retention.test.ts
@@ -0,0 +1,51 @@
+import { expect, it } from "vitest";
+import { setFlagsFromString } from "node:v8";
+import { runInNewContext } from "node:vm";
+import { buildAgentStreamRenderModel } from "./model";
+import type { StreamItem } from "@/types/stream";
+
+function revealHistory(tail: StreamItem[]): WeakRef[] {
+ const windows: WeakRef[] = [];
+ for (let start = tail.length - 20; start >= 20; start -= 20) {
+ const model = buildAgentStreamRenderModel({
+ tail,
+ head: [],
+ historyStart: start,
+ platform: "web",
+ isMobileBreakpoint: false,
+ isTurnActive: false,
+ activeTurnStartedAt: null,
+ });
+ windows.push(new WeakRef(model.history));
+ }
+ return windows;
+}
+
+it("releases obsolete history windows and their source timeline", async () => {
+ setFlagsFromString("--expose_gc");
+ const gc = runInNewContext("gc") as () => void;
+ setFlagsFromString("--no-expose_gc");
+ async function collect() {
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ gc();
+ }
+ }
+ let tail: StreamItem[] = Array.from({ length: 1000 }, (_, i) => ({
+ kind: i % 2 ? "assistant_message" : "user_message",
+ id: `row-${i}`,
+ text: "text",
+ timestamp: new Date(1700000000000 + i * 1000),
+ }));
+ const windows = revealHistory(tail);
+ await collect();
+ expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
+
+ windows.push(...revealHistory(tail));
+ await collect();
+ expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
+
+ tail = [];
+ await collect();
+ expect(windows.every((ref) => ref.deref() === undefined)).toBe(true);
+});
diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts
index e29792ab02..dec4c489b9 100644
--- a/packages/app/src/agent-stream/model.test.ts
+++ b/packages/app/src/agent-stream/model.test.ts
@@ -240,4 +240,47 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
+
+ it.each(["web", "native"] as const)(
+ "reuses rendered history and timing across live-head updates inside a history window on %s",
+ (platform) => {
+ const at = (seed: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seed));
+ const tail: StreamItem[] = [];
+ for (let index = 0; index < 40; index += 1) {
+ tail.push(
+ { ...userMessage(`u${index}`, 0), timestamp: at(index * 2) },
+ { ...assistantMessage(`a${index}`, 0), timestamp: at(index * 2 + 1) },
+ );
+ }
+ const live = (text: string): StreamItem => ({
+ kind: "assistant_message",
+ id: "live",
+ text,
+ timestamp: at(81),
+ });
+ const build = (head: StreamItem[]) =>
+ buildAgentStreamRenderModel({
+ isTurnActive: true,
+ activeTurnStartedAt: tail.at(-2)?.timestamp ?? null,
+ tail,
+ head,
+ platform,
+ isMobileBreakpoint: false,
+ historyStart: 60,
+ });
+
+ const first = build([live("live")]);
+ const second = build([live("live grows")]);
+
+ expect(second.history).toBe(first.history);
+ expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
+ expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
+ expect(first.history).toHaveLength(20);
+ expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
+ expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
+ completedAt: tail[77]?.timestamp,
+ durationMs: 1000,
+ });
+ },
+ );
});
diff --git a/packages/app/src/agent-stream/model.ts b/packages/app/src/agent-stream/model.ts
index 89eb73a7be..92f8be7141 100644
--- a/packages/app/src/agent-stream/model.ts
+++ b/packages/app/src/agent-stream/model.ts
@@ -53,11 +53,29 @@ const splitHistoryCache = new WeakMap<
StreamItem[],
Map>
>();
-const turnTimingCache = new WeakMap<
+// A streamed delta rebuilds the model with the same tail. Everything below is keyed on
+// the rendered tail's identity, so the slice has to be reused, not recomputed.
+const renderedTailCache = new WeakMap<
StreamItem[],
- WeakMap>
+ { historyStart: number; rendered: StreamItem[] }
>();
+function getRenderedTail(tail: StreamItem[], historyStart: number | undefined): StreamItem[] {
+ if (!historyStart) {
+ renderedTailCache.delete(tail);
+ return tail;
+ }
+ const cached = renderedTailCache.get(tail);
+ if (cached?.historyStart === historyStart) {
+ return cached.rendered;
+ }
+ const rendered = tail.slice(historyStart);
+ // Revealing older history replaces the previous window, allowing its arrays and
+ // downstream layouts to be collected while the unchanged source tail stays alive.
+ renderedTailCache.set(tail, { historyStart, rendered });
+ return rendered;
+}
+
function getOrderedItems(params: {
cache: WeakMap>;
source: StreamItem[];
@@ -129,32 +147,6 @@ function splitOrderedTail(params: {
return split;
}
-function getTurnTiming(params: {
- isTurnActive: boolean;
- activeTurnStartedAt: Date | null;
- tail: StreamItem[];
- head: StreamItem[];
-}): StreamTurnTiming {
- let cachedByHead = turnTimingCache.get(params.tail);
- if (!cachedByHead) {
- cachedByHead = new WeakMap();
- turnTimingCache.set(params.tail, cachedByHead);
- }
- let cachedByActivity = cachedByHead.get(params.head);
- if (!cachedByActivity) {
- cachedByActivity = new Map();
- cachedByHead.set(params.head, cachedByActivity);
- }
- const activityKey = `${params.isTurnActive}:${params.activeTurnStartedAt?.getTime() ?? "none"}`;
- const cached = cachedByActivity.get(activityKey);
- if (cached) {
- return cached;
- }
- const timing = deriveStreamTurnTiming(params);
- cachedByActivity.set(activityKey, timing);
- return timing;
-}
-
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
@@ -163,7 +155,7 @@ export function buildAgentStreamRenderModel(
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
- const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
+ const renderedTail = getRenderedTail(input.tail, input.historyStart);
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: renderedTail,
@@ -189,7 +181,7 @@ export function buildAgentStreamRenderModel(
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
- const turnTiming = getTurnTiming({
+ const turnTiming = deriveStreamTurnTiming({
isTurnActive: input.isTurnActive,
activeTurnStartedAt: input.activeTurnStartedAt,
tail: renderedTail,
diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts
index 54160ebcb9..70ed8862ae 100644
--- a/packages/app/src/agent-stream/presentation.test.ts
+++ b/packages/app/src/agent-stream/presentation.test.ts
@@ -11,7 +11,7 @@ import {
type UserMessageItem,
} from "@/types/stream";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
-import { createStreamPresentation } from "./presentation";
+import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { buildAgentStreamRenderModel } from "./model";
const runtime = {
@@ -29,7 +29,7 @@ const runtime = {
} as unknown as PluginClientRuntime;
function installProbe(
- itemType: "assistant_message" | "tool_call" | "reasoning",
+ itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
@@ -160,6 +160,23 @@ describe("stream presentation through installed plugins", () => {
},
);
+ it("addresses a prompt a plugin replaced by the prompt's own id", () => {
+ const harness = streamHarness(installedTransform(installProbe("user_message")));
+ const rendered = rows(
+ harness.send({
+ type: "timeline",
+ provider: "claude",
+ item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
+ }),
+ );
+ const source = rows(harness.source());
+
+ expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
+ expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
+ // The chat outline jumps to the source id; the row it lands on is the plugin's.
+ expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
+ });
+
it("finishes one message before streaming a different message ID", () => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
harness.send(assistant("First\n\nBody", "first"));
diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts
index 8300996348..b89f91f6c0 100644
--- a/packages/app/src/agent-stream/presentation.ts
+++ b/packages/app/src/agent-stream/presentation.ts
@@ -26,10 +26,14 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
- * addresses a message — find, scroll-to-message, history reveal — asks for this.
+ * addresses a message — find, scroll-to-message, history reveal — asks for this. A
+ * plugin row a timeline transformer projected answers for the item it replaced, whose
+ * id is what the chat outline and find hold.
*/
export function getStreamItemMessageId(item: StreamItem): string {
- return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
+ if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
+ if (item.kind === "plugin") return item.sourceId ?? item.id;
+ return item.id;
}
/**
diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx
index 47f2766a30..65f535db11 100644
--- a/packages/app/src/agent-stream/strategy-native.tsx
+++ b/packages/app/src/agent-stream/strategy-native.tsx
@@ -42,6 +42,7 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
+import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
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 },
+ {
+ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
+ },
+ ],
+ [
+ baseListContentContainerStyle,
+ streamKeyboardInset.contentContainerPaddingBottom,
+ topOverlayInset,
],
- [baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx
index 9078b0f1c3..1f61d0a14c 100644
--- a/packages/app/src/agent-stream/strategy-web.test.tsx
+++ b/packages/app/src/agent-stream/strategy-web.test.tsx
@@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({
withUnistyles: (Component: React.ComponentType) => Component,
}));
-function userMessage(index: number): StreamItem {
+function userMessage(index: number): Extract {
return {
kind: "user_message",
id: `message-${index}`,
@@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
};
}
+function MeasuredTestRow({ item }: { item: StreamItem }) {
+ const [loaded, setLoaded] = React.useState(false);
+ const load = React.useCallback(() => setLoaded(true), []);
+ const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
+ const measure = React.useCallback(
+ (node: HTMLDivElement | null) => {
+ if (!node?.parentElement) return;
+ Object.defineProperty(node.parentElement, "offsetHeight", {
+ configurable: true,
+ value: height,
+ });
+ },
+ [height],
+ );
+ return (
+
+
+ {loaded ? "Loaded" : "Load"}
+
+ {item.id}
+
+ );
+}
+
describe("createWebStreamStrategy", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
+ it("retains a row's DOM and local state when recent history becomes virtualized", () => {
+ const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ const item = userMessage(0);
+ const renderRow = (rowItem: StreamItem) => ;
+ const input: StreamRenderInput = {
+ agentId: "agent",
+ segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
+ boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
+ renderers: {
+ renderHistoryVirtualizedRow: renderRow,
+ renderHistoryMountedRow: renderRow,
+ renderLiveHeadRow: renderRow,
+ renderLiveAuxiliary: () => null,
+ },
+ 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,
+ };
+ act(() => root?.render(strategy.render(input)));
+ const row = container.querySelector('[data-history-row-id="message-0"]')!;
+ act(() => row.querySelector("button")!.click());
+ expect(row.querySelector("button")!.textContent).toBe("Loaded");
+ const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!;
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 });
+ act(() =>
+ root?.render(
+ strategy.render({
+ ...input,
+ segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] },
+ boundary: { ...input.boundary, hasVirtualizedHistory: true },
+ }),
+ ),
+ );
+ expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row);
+ expect(row.isConnected).toBe(true);
+ expect(row.querySelector("button")!.textContent).toBe("Loaded");
+ act(() =>
+ root?.render(
+ strategy.render({
+ ...input,
+ segments: {
+ historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)],
+ historyMounted: [userMessage(2)],
+ liveHead: [],
+ },
+ boundary: { ...input.boundary, hasVirtualizedHistory: true },
+ }),
+ ),
+ );
+ expect(
+ (container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform,
+ ).toBe("translateY(120px)");
+ expect(row.querySelector("button")!.textContent).toBe("Loaded");
+ });
+
it("keeps the timeline width stable with an overlay scrollbar", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
container = document.createElement("div");
diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx
index d0b7eda0df..f1c806fdca 100644
--- a/packages/app/src/agent-stream/strategy-web.tsx
+++ b/packages/app/src/agent-stream/strategy-web.tsx
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
+import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
@@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = {
flexShrink: 0,
};
+const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
+
const streamRowStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
@@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom(
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
}
+// Position updates do not change a row's content. Keep renderer work separate
+// from virtual measurements while retaining the same component across lanes.
+const StreamRowContent = React.memo(function StreamRowContent({
+ item,
+ index,
+ items,
+ render,
+}: {
+ revision?: StreamRenderInput["liveHeadRowRevision"];
+ item: StreamItem;
+ index: number;
+ items: StreamItem[];
+ render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"];
+}) {
+ return render(item, index, items);
+});
+
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments: inputSegments,
@@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
+ initialRect: {
+ width: scrollContainerRef.current?.clientWidth ?? 0,
+ height: scrollContainerRef.current?.clientHeight ?? 0,
+ },
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
@@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduler.schedule();
});
- // ResizeObserver runs before paint. Batch measurements and commit React's new
- // positions here so a resized image cannot overlap rows for an extra frame.
- // Use resizeItem exclusively; measureElement would install a second observer.
+ const measureVirtualizedRows = useStableEvent(() => {
+ let resized = false;
+ for (const element of contentRef.current?.querySelectorAll(
+ "[data-history-row-id][data-index]",
+ ) ?? []) {
+ const index = Number(element.dataset.index);
+ const height = measureVirtualElement(element, undefined, rowVirtualizer);
+ if (rowVirtualizer.measurementsCache[index]?.size === height) continue;
+ rowVirtualizer.resizeItem(index, height);
+ resized = true;
+ }
+ return resized;
+ });
+
+ // Measure the committed DOM rather than queued observer entries: flushSync can
+ // also commit an image's pending intrinsic dimensions, invalidating those entries.
const virtualRowObserver = useMemo(
() =>
- new ResizeObserver((entries) => {
+ new ResizeObserver(() => {
if (!isActiveRef.current) return;
flushSync(() => {
- for (const entry of entries) {
- const element = entry.target as HTMLElement;
- if (!element.isConnected) continue;
- rowVirtualizer.resizeItem(
- Number(element.dataset.index),
- measureVirtualElement(element, entry, rowVirtualizer),
- );
- }
+ measureVirtualizedRows();
});
}),
- [rowVirtualizer],
+ [measureVirtualizedRows],
);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Apply row positions and their scroll correction in the same paint, including
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
useLayoutEffect(() => {
+ // Commit positions for every resized row before accepting reading geometry.
+ // Child layout can change during this commit, ahead of ResizeObserver delivery.
+ if (!isActive || measureVirtualizedRows()) return;
reconcileReadingPosition();
- }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
+ }, [
+ contentWidth,
+ isActive,
+ segments,
+ virtualRows,
+ measureVirtualizedRows,
+ reconcileReadingPosition,
+ ]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
@@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}
const observer = new ResizeObserver(() => {
+ flushSync(() => {
+ measureVirtualizedRows();
+ });
const nextGeometry = getObservedViewportGeometry(scrollContainer);
if (pendingResumeGeometryCheckRef.current) {
pendingResumeGeometryCheckRef.current = false;
@@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
+ measureVirtualizedRows,
]);
useLayoutEffect(() => {
@@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[],
);
- const virtualRowsContainerStyle = useMemo((): CSSProperties => {
- return {
- position: "relative",
- width: "100%",
+ const virtualRowsContainerStyle = useMemo(
+ (): CSSProperties => ({
height: virtualTotalSize,
- };
- }, [virtualTotalSize]);
+ flexShrink: 0,
+ }),
+ [virtualTotalSize],
+ );
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
@@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
- {renderHistoryMountedRow(item, index, segments.historyMounted)}
+
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
- void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
- {renderLiveHeadRow(item, index, segments.liveHead)}
+
));
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
@@ -1244,6 +1298,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{historyStartSlot}
- {shouldUseVirtualizer ? (
-
- {virtualRows.map((virtualRow) => {
+
+
+ {[
+ ...virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
- if (!item) {
- return null;
- }
+ if (!item) return null;
return (
- {renderHistoryVirtualizedRow(
- item,
- virtualRow.index,
- segments.historyVirtualized,
- )}
+
);
- })}
-
- ) : null}
- {mountedRows}
+ }),
+ ...mountedRows,
+ ]}
+
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
diff --git a/packages/app/src/agent-stream/stream-tick.bench.ts b/packages/app/src/agent-stream/stream-tick.bench.ts
new file mode 100644
index 0000000000..dc1fecb881
--- /dev/null
+++ b/packages/app/src/agent-stream/stream-tick.bench.ts
@@ -0,0 +1,132 @@
+import { bench, describe } from "vitest";
+import type { StreamItem, ToolCallItem } from "@/types/stream";
+import {
+ prepareToolCallHistory,
+ projectToolCallDetailLevel,
+} from "@/tool-calls/detail-level/projection";
+import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
+import { layoutStream } from "./layout";
+import { buildAgentStreamRenderModel } from "./model";
+import { resolveStreamRenderStrategy } from "./strategy-resolver";
+
+// One coalesced delta replaces the stream head. This is the pure work AgentStreamView
+// does per delta on a long timeline, without React:
+// npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts
+
+const TIMELINE_ITEMS = 2_000;
+const TICKS = 200;
+
+function timestamp(seed: number): Date {
+ return new Date(1_700_000_000_000 + seed * 1000);
+}
+
+function toolCall(id: string, seed: number): ToolCallItem {
+ return {
+ kind: "tool_call",
+ id,
+ timestamp: timestamp(seed),
+ payload: {
+ source: "agent",
+ data: {
+ provider: "claude",
+ callId: id,
+ name: "shell",
+ status: "completed",
+ error: null,
+ detail: { type: "shell", command: id },
+ },
+ },
+ };
+}
+
+function buildTimeline(count: number): StreamItem[] {
+ const items: StreamItem[] = [];
+ let seed = 0;
+ while (items.length < count) {
+ const turn = items.length;
+ items.push({
+ kind: "user_message",
+ id: `u${turn}`,
+ text: "prompt",
+ timestamp: timestamp(seed++),
+ });
+ items.push(toolCall(`t${turn}a`, seed++));
+ items.push(toolCall(`t${turn}b`, seed++));
+ items.push({
+ kind: "assistant_message",
+ id: `a${turn}`,
+ text: "answer",
+ timestamp: timestamp(seed++),
+ });
+ }
+ return items.slice(0, count);
+}
+
+function buildHeads(base: StreamItem[], ticks: number): StreamItem[][] {
+ let text = "";
+ const heads: StreamItem[][] = [];
+ for (let tick = 0; tick < ticks; tick += 1) {
+ text += "token ";
+ heads.push([
+ {
+ kind: "assistant_message",
+ id: "live",
+ text,
+ timestamp: timestamp(base.length + tick),
+ },
+ ]);
+ }
+ return heads;
+}
+
+function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: StreamItem[][] }) {
+ const { tail, historyStart, heads } = input;
+ const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
+ const prepared = prepareToolCallHistory("overview", tail);
+ const boundaryId = tail[historyStart]?.id ?? null;
+ const activeTurnStartedAt = tail.at(-1)?.timestamp ?? null;
+ for (const head of heads) {
+ const projected = projectToolCallDetailLevel({
+ level: "overview",
+ tail,
+ head,
+ preparedHistory: prepared,
+ isTurnActive: true,
+ });
+ const boundaryIndex = boundaryId
+ ? projected.tail.findIndex((item) => item.id === boundaryId)
+ : -1;
+ const model = buildAgentStreamRenderModel({
+ isTurnActive: true,
+ activeTurnStartedAt,
+ tail: projected.tail,
+ head: projected.head,
+ platform: "web",
+ isMobileBreakpoint: false,
+ historyStart: boundaryIndex >= 0 ? boundaryIndex : historyStart,
+ });
+ layoutStream({
+ strategy,
+ isTurnActive: true,
+ history: model.history,
+ liveHead: model.segments.liveHead,
+ timingByAssistantId: model.turnTiming.byAssistantId,
+ });
+ }
+}
+
+const tail = buildTimeline(TIMELINE_ITEMS);
+const heads = buildHeads(tail, TICKS);
+const defaultWindowStart = findMountedWindowStart({
+ items: tail,
+ minMountedCount: getMountedRecentStreamItems(),
+});
+
+describe(`${TICKS} streamed deltas over a ${TIMELINE_ITEMS}-item timeline`, () => {
+ bench("default mounted window (recent items only)", () => {
+ runTicks({ tail, historyStart: defaultWindowStart, heads });
+ });
+ bench("history fully revealed (scrolled to top)", () => {
+ runTicks({ tail, historyStart: 0, heads });
+ });
+});
diff --git a/packages/app/src/agent-stream/top-overlay.ts b/packages/app/src/agent-stream/top-overlay.ts
new file mode 100644
index 0000000000..24cd7f9592
--- /dev/null
+++ b/packages/app/src/agent-stream/top-overlay.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from "react";
+import { isNative } from "@/constants/platform";
+
+/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
+export const supportsStreamTopOverlay = isNative;
+
+const StreamTopOverlayContext = createContext(0);
+
+/** Visual-top clearance supplied by the containing layout, in points. */
+export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
+
+export function useStreamTopOverlayInset(): number {
+ return useContext(StreamTopOverlayContext);
+}
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 42168f052d..9874158dcc 100644
--- a/packages/app/src/agent-stream/use-stream-history-window.ts
+++ b/packages/app/src/agent-stream/use-stream-history-window.ts
@@ -70,10 +70,11 @@ export function useStreamHistoryWindow(input: {
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
- const boundaryIndex =
- window.agentId === agentId && window.boundaryItemId !== null
- ? items.findIndex((item) => item.id === window.boundaryItemId)
- : -1;
+ const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
+ 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.boundaryItemId === null) {
diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx
index 12f628da4f..6526e7352b 100644
--- a/packages/app/src/agent-stream/view.tsx
+++ b/packages/app/src/agent-stream/view.tsx
@@ -607,16 +607,21 @@ const AgentStreamViewComponent = forwardRef
- new Set(
- [...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
- getStreamItemMessageId,
- ),
- ),
- [baseRenderModel.history, baseRenderModel.segments.liveHead],
+ // group of block rows, so these hold message ids and never row ids. Only the head
+ // changes per streamed delta, so the history set is keyed on mounted history alone.
+ const mountedHistoryMessageIds = useMemo(
+ () => new Set(baseRenderModel.history.map(getStreamItemMessageId)),
+ [baseRenderModel.history],
);
+ const mountedMessageIds = useMemo(() => {
+ const liveHeadMessageIds = new Set(
+ baseRenderModel.segments.liveHead.map(getStreamItemMessageId),
+ );
+ return {
+ has: (messageId: string) =>
+ mountedHistoryMessageIds.has(messageId) || liveHeadMessageIds.has(messageId),
+ };
+ }, [baseRenderModel.segments.liveHead, mountedHistoryMessageIds]);
const chatOutline = useChatOutline({
agentId,
serverId: resolvedServerId,
@@ -626,7 +631,7 @@ const AgentStreamViewComponent = forwardRef
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 2eb4e5185a..2d6525b27f 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -1,3 +1,4 @@
+import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
@@ -35,7 +36,7 @@ import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
-import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
+import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
@@ -102,7 +103,6 @@ import {
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
-import { polyfillNavigator } from "@/polyfills/navigator";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -116,6 +116,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
+import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -138,7 +139,6 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
-polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
@@ -550,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}
const workspaceChrome = (
+
{!isCompactLayout ? (
{themedSidebarChrome}
@@ -625,7 +626,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
- return {content} ;
+ return (
+
+ {content}
+
+ );
}
function SidebarChrome({
@@ -660,7 +665,7 @@ function MobileGestureWrapper({
return (
-
+
{children}
@@ -863,7 +868,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
- pathname === "/usage" ||
routeHasKnownHost);
return {children} ;
@@ -896,7 +900,6 @@ function RootStack() {
-
diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx
index 281ec33555..67db3c2c96 100644
--- a/packages/app/src/app/new.tsx
+++ b/packages/app/src/app/new.tsx
@@ -1,6 +1,15 @@
import { useLocalSearchParams } from "expo-router";
+import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
+import {
+ ComposerEditingScope,
+ WorkspaceComposerEditingSessionsProvider,
+} from "@/composer/editing-session";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
+import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys";
+
+/** New workspace drafts share one editing-session home, keyed like a tab by draft. */
+const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace";
export default function NewWorkspaceRoute() {
const params = useLocalSearchParams<{
@@ -23,16 +32,28 @@ export default function NewWorkspaceRoute() {
draftId ?? null,
]);
+ // The shell remounts this route when the viewport crosses the compact breakpoint;
+ // the draft's editing session (fullscreen, retained caret) lives above the shell.
+ const draftKey = buildNewWorkspaceDraftKey(draftId);
+ const sessionTabIds = useMemo(() => [draftKey], [draftKey]);
+
return (
-
+
+
+
+
+
);
}
diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx
deleted file mode 100644
index 2928cca3f3..0000000000
--- a/packages/app/src/app/usage.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
-import { UsageScreen } from "@/usage";
-
-export default function UsageRoute() {
- return (
-
-
-
- );
-}
diff --git a/packages/app/src/appearance/provider.tsx b/packages/app/src/appearance/provider.tsx
index d52864e435..77addad9a3 100644
--- a/packages/app/src/appearance/provider.tsx
+++ b/packages/app/src/appearance/provider.tsx
@@ -21,6 +21,7 @@ import {
} from "@/plugins/themes";
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
import { applyAppearance } from "./apply";
+import { AppearanceSystemBars } from "./system-bars";
interface ContributedThemes {
options: PluginThemeOption[];
@@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
if (!hasAppliedAppearance) return null;
return (
- {children}
+
+
+ {children}
+
);
}
diff --git a/packages/app/src/appearance/system-bars.android.tsx b/packages/app/src/appearance/system-bars.android.tsx
new file mode 100644
index 0000000000..f36fb60599
--- /dev/null
+++ b/packages/app/src/appearance/system-bars.android.tsx
@@ -0,0 +1,11 @@
+import { SystemBars } from "react-native-edge-to-edge";
+import { withUnistyles } from "react-native-unistyles";
+import type { Theme } from "@/styles/theme";
+
+function ThemeSystemBars({ colorScheme }: Pick) {
+ return ;
+}
+
+export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({
+ colorScheme: theme.colorScheme,
+}));
diff --git a/packages/app/src/appearance/system-bars.tsx b/packages/app/src/appearance/system-bars.tsx
new file mode 100644
index 0000000000..930579bc4a
--- /dev/null
+++ b/packages/app/src/appearance/system-bars.tsx
@@ -0,0 +1,3 @@
+export function AppearanceSystemBars() {
+ return null;
+}
diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts
index 65f123cb3d..cebb4ca76d 100644
--- a/packages/app/src/assets/acp-provider-icons.ts
+++ b/packages/app/src/assets/acp-provider-icons.ts
@@ -48,6 +48,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
goose:
'\n \n \n',
grok: '\n \n \n \n',
+ hoplite:
+ ' \n',
junie:
'\n \n \n \n \n',
kilo: '\n \n \n',
diff --git a/packages/app/src/assets/acp-provider-icons/hoplite.svg b/packages/app/src/assets/acp-provider-icons/hoplite.svg
new file mode 100644
index 0000000000..4d9c443429
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/hoplite.svg
@@ -0,0 +1 @@
+
diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts
index 12921b855c..3390d0cfa9 100644
--- a/packages/app/src/assistant-file-links/parse.test.ts
+++ b/packages/app/src/assistant-file-links/parse.test.ts
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
+
+ it("decodes percent-encoded relative markdown hrefs", () => {
+ // markdown-it percent-encodes non-ASCII characters in hrefs, so a link
+ // written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
+ expect(
+ parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
+ workspaceRoot: "/Users/test/project",
+ decodeHref: true,
+ }),
+ ).toEqual({
+ raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
+ path: "/Users/test/project/docs/reports/开户赠金.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ });
+
+ it("decodes percent-encoded spaces in relative markdown hrefs", () => {
+ expect(
+ parseAssistantFileLink("docs/my%20file.md#L5", {
+ workspaceRoot: "/Users/test/project",
+ decodeHref: true,
+ }),
+ ).toEqual({
+ raw: "docs/my%20file.md#L5",
+ path: "/Users/test/project/docs/my file.md",
+ lineStart: 5,
+ lineEnd: undefined,
+ });
+ });
+
+ it("decodes percent-encoded absolute hrefs with line suffixes", () => {
+ expect(
+ parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
+ workspaceRoot: "/Users/test/project",
+ decodeHref: true,
+ }),
+ ).toEqual({
+ raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
+ path: "/tmp/开户.md",
+ lineStart: 3,
+ lineEnd: undefined,
+ });
+ });
+
+ it("decodes percent-encoded home-relative hrefs", () => {
+ expect(
+ parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
+ workspaceRoot: "/Users/test/project",
+ decodeHref: true,
+ }),
+ ).toEqual({
+ raw: "~/notes/%E5%BC%80%E6%88%B7.md",
+ path: "~/notes/开户.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ });
+
+ it("decodes percent-encoded Windows hrefs", () => {
+ expect(
+ parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
+ workspaceRoot: "C:/repo",
+ decodeHref: true,
+ }),
+ ).toEqual({
+ raw: "C:/repo/my%20dir/app.tsx#L8",
+ path: "C:/repo/my dir/app.tsx",
+ lineStart: 8,
+ lineEnd: undefined,
+ });
+ });
+
+ it("keeps literal percent signs in raw paths that are not hrefs", () => {
+ // Inline-code and linkified tokens are raw file paths: a real file named
+ // "my%20file.md" must not be decoded into "my file.md".
+ expect(
+ parseAssistantFileLink("docs/my%20file.md", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "docs/my%20file.md",
+ path: "/Users/test/project/docs/my%20file.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ expect(
+ parseAssistantFileLink("/tmp/my%20file.md:3", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "/tmp/my%20file.md:3",
+ path: "/tmp/my%20file.md",
+ lineStart: 3,
+ lineEnd: undefined,
+ });
+ });
+
+ it("keeps non-ASCII characters in raw absolute paths", () => {
+ // `new URL` percent-encodes non-ASCII, so raw paths must not be routed
+ // through URL parsing — `/tmp/开户.md` must round-trip unchanged.
+ expect(
+ parseAssistantFileLink("/tmp/开户.md", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "/tmp/开户.md",
+ path: "/tmp/开户.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ expect(
+ parseAssistantFileLink("/tmp/开户.md#L4", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "/tmp/开户.md#L4",
+ path: "/tmp/开户.md",
+ lineStart: 4,
+ lineEnd: undefined,
+ });
+ });
});
describe("normalizeInlinePathTarget", () => {
@@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => {
});
});
});
+
+describe("encoded filename line markers", () => {
+ it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
+ "keeps encoded line syntax in the filename %s",
+ (href) => {
+ expect(
+ parseAssistantFileLink(href, {
+ workspaceRoot: "/workspace",
+ decodeHref: true,
+ }),
+ ).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
+ expect(
+ parseAssistantFileLink(href, {
+ workspaceRoot: "/workspace",
+ decodeHref: true,
+ })?.lineStart,
+ ).toBeUndefined();
+ },
+ );
+});
diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts
index 092f3b4dcd..0a8089acca 100644
--- a/packages/app/src/assistant-file-links/parse.ts
+++ b/packages/app/src/assistant-file-links/parse.ts
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
+ /**
+ * True only when `value` is a markdown href — the renderer percent-encodes
+ * non-ASCII and space characters, so the path must be decoded. Raw path
+ * text (inline code, linkified tokens) is not encoded; decoding it would
+ * send a real file named `my%20file.md` to `my file.md`.
+ */
+ decodeHref?: boolean;
}
export type AssistantFileLinkClassification =
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
-function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
+function parseAssistantInlinePathLink(
+ value: string,
+ options: Pick = {},
+): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
}
- const normalizedPath = normalizePathToken(inlinePathTarget.path);
+ let pathPart = inlinePathTarget.path;
+ if (options.decodeHref) {
+ pathPart = safeDecodeURIComponent(pathPart);
+ }
+ const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
return null;
}
- if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
+ if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
return null;
}
- const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
+ const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
if (inlinePathTarget) {
return inlinePathTarget;
}
- const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
- if (windowsPathMatch) {
- const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
- if (!normalizedPath) {
- return null;
- }
-
- const lines = parseLineFragment(windowsPathMatch[2] ?? "");
- if (!lines) {
- return null;
- }
-
- return {
- raw: value,
- path: normalizedPath,
- ...lines,
- };
+ const windowsTarget = parseWindowsFileLink(trimmed, value, options);
+ if (windowsTarget) {
+ return windowsTarget;
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
+ decodeHref: options.decodeHref,
});
if (relativeTarget) {
return relativeTarget;
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
return null;
}
- let parsedUrl: URL;
- try {
- parsedUrl = new URL(trimmed, "http://paseo.invalid");
- } catch {
- return null;
+ // Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
+ // normalize before decode. Raw paths must not: `new URL` percent-encodes
+ // non-ASCII characters, which would corrupt `/tmp/开户.md` into the
+ // still-encoded name.
+ let pathPart: string;
+ let hashPart: string;
+ if (options.decodeHref) {
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(trimmed, "http://paseo.invalid");
+ } catch {
+ return null;
+ }
+ pathPart = safeDecodeURIComponent(parsedUrl.pathname);
+ hashPart = parsedUrl.hash;
+ } else {
+ const hashIndex = trimmed.indexOf("#");
+ pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
+ hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
}
- const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
+ const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
- const lines = parseLineFragment(parsedUrl.hash);
+ const lines = parseLineFragment(hashPart);
if (!lines) {
return null;
}
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
return isPlausibleAssistantLocalPath(path);
}
+function parseWindowsFileLink(
+ trimmed: string,
+ raw: string,
+ options: Pick = {},
+): InlinePathTarget | null {
+ const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
+ if (!windowsPathMatch) {
+ return null;
+ }
+
+ let pathPart = windowsPathMatch[1] ?? "";
+ if (options.decodeHref) {
+ pathPart = safeDecodeURIComponent(pathPart);
+ }
+ const normalizedPath = normalizePathToken(pathPart);
+ if (!normalizedPath) {
+ return null;
+ }
+
+ const lines = parseLineFragment(windowsPathMatch[2] ?? "");
+ if (!lines) {
+ return null;
+ }
+
+ return {
+ raw,
+ path: normalizedPath,
+ ...lines,
+ };
+}
+
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
- const parsed = parseLocalPathParts(value);
+ const parsed = parseLocalPathParts(value, options);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
-): { path: string; lines: Pick } | null {
+ options: Pick = {},
+): {
+ path: string;
+ lines: Pick;
+} | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
@@ -436,14 +486,19 @@ function parseLocalPathParts(
return null;
}
+ // Split literal line markers before decoding; encoded punctuation belongs
+ // to the filename. Raw inline-code/path text stays untouched.
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
- if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
+ let pathPart = inlinePathTarget.path;
+ if (options.decodeHref) {
+ pathPart = safeDecodeURIComponent(pathPart);
+ }
+ if (!isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
-
return {
- path: inlinePathTarget.path,
+ path: pathPart,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
@@ -455,12 +510,17 @@ function parseLocalPathParts(
return null;
}
- if (!isPlausibleAssistantLocalPath(beforeHash)) {
+ let decodedBeforeHash = beforeHash;
+ if (options.decodeHref) {
+ decodedBeforeHash = safeDecodeURIComponent(beforeHash);
+ }
+ const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
+ if (!isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
return {
- path: beforeHash,
+ path: decodedBeforeHash,
lines: fragmentLines,
};
}
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
- workspaceRoot?: string,
+ options: AssistantHrefParseOptions = {},
): boolean {
- const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
+ const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
- const parsed = parseLocalPathParts(value);
+ const parsed = parseLocalPathParts(value, options);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts
index 312bfff1fd..384c3b6857 100644
--- a/packages/app/src/assistant-file-links/resolver.test.ts
+++ b/packages/app/src/assistant-file-links/resolver.test.ts
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
+import MarkdownIt from "markdown-it";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
import {
classifyForResolution,
fetchDaemonResolution,
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
+it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
+ try {
+ await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
+ await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
+ const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
+ const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
+ expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
+ const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
+ expect(resolution.kind).toBe("resolved");
+ if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
+ throw new Error("Expected a directly resolved file link");
+ }
+ expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
+ } finally {
+ await rm(workspaceRoot, { recursive: true, force: true });
+ }
+});
+
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
};
describe("classifyForResolution", () => {
+ it.each([
+ ["C:/repo/my%20file.md", undefined],
+ ["C:\\repo\\my%20file.md", undefined],
+ ["C:/repo/my%20file.md:12", 12],
+ ["C:\\repo\\my%20file.md:12", 12],
+ ])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
+ for (const text of [href, undefined]) {
+ const result = classifyForResolution(
+ { href, text, sourceType: "inline-code" },
+ { workspaceRoot: "C:/repo" },
+ );
+ expect(result).toMatchObject({
+ kind: "resolved",
+ value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
+ });
+ }
+ expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
+ kind: "resolved",
+ value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
+ });
+ });
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
+
+ it("decodes percent-encoded markdown hrefs", () => {
+ const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
+
+ expect(result).toEqual({
+ kind: "resolved",
+ value: {
+ kind: "file",
+ target: {
+ raw: "docs/my%20file.md",
+ path: "/Users/test/project/docs/my file.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ },
+ },
+ });
+ });
+
+ it("keeps literal percent signs in inline-code paths", () => {
+ const result = classifyForResolution(
+ {
+ href: "docs/my%20file.md",
+ text: "docs/my%20file.md",
+ sourceType: "inline-code",
+ },
+ CONTEXT,
+ );
+
+ expect(result).toEqual({
+ kind: "needsLookup",
+ ambiguousQuery: "docs/my%20file.md",
+ token: "docs/my%20file.md",
+ target: {
+ raw: "docs/my%20file.md",
+ path: "/Users/test/project/docs/my%20file.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ },
+ });
+ });
});
describe("fetchDaemonResolution", () => {
diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts
index 5fa4849fc8..774d39278b 100644
--- a/packages/app/src/assistant-file-links/resolver.ts
+++ b/packages/app/src/assistant-file-links/resolver.ts
@@ -108,13 +108,15 @@ export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
- const token = getAssistantFileLinkToken(source).trim();
+ const tokenInfo = getAssistantFileLinkTokenInfo(source);
+ const token = tokenInfo.token.trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
+ decodeHref: tokenInfo.fromHref,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -148,14 +150,26 @@ export function classifyForResolution(
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
- if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
+ return getAssistantFileLinkTokenInfo(source).token;
+}
+
+// Markdown link hrefs are percent-encoded by the renderer; inline-code and
+// linkified text are raw file paths that must not be decoded.
+function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
+ token: string;
+ fromHref: boolean;
+} {
+ if (source.sourceType === "inline-code") {
+ return { token: source.text?.trim() || source.href, fromHref: false };
+ }
+ if (isLinkifiedSource(source)) {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
- return text;
+ return { token: text, fromHref: false };
}
}
- return source.href;
+ return { token: source.href, fromHref: true };
}
export function getAmbiguousSuggestionQuery(
diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts
index 194405d2ec..603645f0fe 100644
--- a/packages/app/src/assistant-file-links/use-file-link.ts
+++ b/packages/app/src/assistant-file-links/use-file-link.ts
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
canResolveFile(source: AssistantFileLinkSource): boolean;
}
+interface AssistantFileLinkLookupKey {
+ query: string;
+ path: string;
+ lineStart: number | null;
+ lineEnd: number | null;
+}
+
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
- string,
+ AssistantFileLinkLookupKey | null,
];
-const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
+const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
- token: resolution.token,
+ resolution,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
- token: capturedResolution.token,
+ resolution: capturedResolution,
});
const run = async () => {
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
- token: string;
+ resolution: Extract;
}): AssistantFileLinkQueryKey {
- return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
+ return [
+ "assistantFileLink",
+ input.serverId ?? null,
+ input.workspaceRoot ?? null,
+ {
+ query: input.resolution.ambiguousQuery,
+ path: input.resolution.target.path,
+ lineStart: input.resolution.target.lineStart ?? null,
+ lineEnd: input.resolution.target.lineEnd ?? null,
+ },
+ ];
}
async function dispatchResolvedLink(input: {
diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts
index d6fce078f2..9bf982c6e8 100644
--- a/packages/app/src/attachments/utils.test.ts
+++ b/packages/app/src/attachments/utils.test.ts
@@ -42,6 +42,18 @@ describe("fileUriToPath", () => {
});
describe("localFileSourceToPath", () => {
+ it.each([
+ "https://example.com/image%20name.png",
+ "data:image/png;base64,abc%20",
+ "blob:https://example.com/image%20name",
+ ])("preserves a direct Markdown image URI: %s", (source) => {
+ expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source);
+ });
+
+ it("preserves malformed percent escapes rather than throwing", () => {
+ expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png");
+ });
+
it("decodes markdown-encoded Windows drive-letter paths", () => {
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
});
diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts
index 5fa0334359..46483bf0e7 100644
--- a/packages/app/src/attachments/utils.ts
+++ b/packages/app/src/attachments/utils.ts
@@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string {
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
-export function localFileSourceToPath(source: string): string {
+export function localFileSourceToPath(
+ source: string,
+ // Markdown destinations are URI-encoded; plain filesystem paths are not.
+ { decodeHref = false }: { decodeHref?: boolean } = {},
+): string {
+ if (/^(https?:|data:|blob:)/i.test(source)) {
+ return source;
+ }
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
- } else if (isMarkdownEncodedWindowsDrivePath(source)) {
+ } else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts
index fbd37c15e7..cc8ad8fc11 100644
--- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts
+++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
-import {
- getBottomSheetVisibleContentHeight,
- getCompactSheetSafeAreaPadding,
-} from "@/components/adaptive-modal-sheet-layout";
+import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => {
@@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({});
});
});
-
-describe("getBottomSheetVisibleContentHeight", () => {
- it("stops subtracting the retained keyboard height after the keyboard hides", () => {
- const layout = {
- containerHeight: 874,
- contentPosition: 88,
- handleHeight: 24,
- keyboardHeight: 344,
- };
-
- expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
- expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
- });
-});
diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts
index 91fc62274e..b38db5750c 100644
--- a/packages/app/src/components/adaptive-modal-sheet-layout.ts
+++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts
@@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding {
footerPaddingBottom?: number;
}
-interface BottomSheetVisibleContentHeightInput {
- containerHeight: number;
- contentPosition: number;
- handleHeight: number;
- keyboardHeight: number;
- isKeyboardVisible: boolean;
-}
-
-export function getBottomSheetVisibleContentHeight({
- containerHeight,
- contentPosition,
- handleHeight,
- keyboardHeight,
- isKeyboardVisible,
-}: BottomSheetVisibleContentHeightInput): number {
- "worklet";
- return Math.max(
- 0,
- containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
- );
-}
-
export function getCompactSheetSafeAreaPadding({
isCompact,
isKeyboardVisible,
diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index 4ceb43d917..d4d833e3f1 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
-import type { ReactNode } 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
@@ -12,22 +12,16 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
-import {
- KEYBOARD_STATUS,
- useBottomSheetInternal,
- type BottomSheetBackgroundProps,
-} from "@gorhom/bottom-sheet";
-import Animated, { useAnimatedStyle } from "react-native-reanimated";
-import { ArrowLeft, Search, X } from "lucide-react-native";
+import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
+import Animated from "react-native-reanimated";
+import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
+ SheetVisibleFrame,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
-import {
- getBottomSheetVisibleContentHeight,
- getCompactSheetSafeAreaPadding,
-} from "@/components/adaptive-modal-sheet-layout";
+import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
@@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
- bottomSheetVisibleContent: {
- minHeight: 0,
- overflow: "hidden",
- },
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
@@ -233,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({
const WEB_EXIT_DURATION_MS = 160;
-function SheetBackground({ style }: BottomSheetBackgroundProps) {
+/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
+export function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
() => [
@@ -249,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return ;
}
+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.
+ */
+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 (
+
+ {children}
+
+ );
+}
+
/**
* The sheet body, indented to the sheet's content inset.
*
@@ -263,37 +287,34 @@ function SheetContent({ style, children }: { style: StyleProp; childr
return {children} ;
}
-function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
- const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
- useBottomSheetInternal();
- const visibleContentStyle = useAnimatedStyle(() => {
- const { containerHeight, handleHeight } = animatedLayoutState.get();
- if (containerHeight < 0 || handleHeight < 0) {
- return { height: 0 };
- }
-
- const initialDetentPosition = animatedDetentsState.get().detents?.[0];
- const contentPosition =
- initialDetentPosition == null
- ? animatedPosition.get()
- : Math.min(animatedPosition.get(), initialDetentPosition);
-
- const keyboardState = animatedKeyboardState.get();
- return {
- height: getBottomSheetVisibleContentHeight({
- containerHeight,
- contentPosition,
- handleHeight,
- keyboardHeight: keyboardState.heightWithinContainer,
- isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN,
- }),
- };
- }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
-
+/**
+ * An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
+ * actions line up with it at one size. Pass it through `SheetHeader.actions`.
+ */
+export function SheetHeaderIconButton({
+ icon: Icon,
+ accessibilityLabel,
+ onPress,
+ testID,
+}: {
+ icon: LucideIcon;
+ accessibilityLabel: string;
+ onPress: () => void;
+ testID?: string;
+}) {
+ const { theme } = useUnistyles();
return (
-
- {children}
-
+
+ {({ pressed }) => (
+
+ )}
+
);
}
@@ -353,19 +374,11 @@ export function SheetHeaderView({
{header.actions ? {header.actions} : null}
{showCloseButton ? (
-
- {({ pressed }) => (
-
- )}
-
+ />
) : null}
{search ? (
@@ -471,8 +484,6 @@ export interface AdaptiveModalSheetProps {
bodyStyle?: StyleProp;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp;
- /** Size compact sheet content to the live snap height instead of its largest snap point. */
- sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null;
}
@@ -493,10 +504,8 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
bodyStyle,
- sizeContentToCurrentSnapPoint = true,
contextBridge = null,
}: AdaptiveModalSheetProps) {
- const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
@@ -519,14 +528,14 @@ export function AdaptiveModalSheet({
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
- const footerView = footer ? (
-
- {footer}
-
- ) : null;
- const handleIndicatorStyle = useMemo(
- () => ({ backgroundColor: theme.colors.palette.zinc[600] }),
- [theme.colors.palette.zinc],
+ const footerView = useMemo(
+ () =>
+ footer ? (
+
+ {footer}
+
+ ) : null,
+ [footer, footerClearanceStyle, footerContainerStyle],
);
useEffect(() => {
if (!isWeb && visible) {
@@ -628,7 +637,6 @@ export function AdaptiveModalSheet({
)}
- {footerView}
>
);
@@ -644,17 +652,13 @@ export function AdaptiveModalSheet({
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={SheetBackground}
- handleIndicatorStyle={handleIndicatorStyle}
+ handleComponent={SheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
- {sizeContentToCurrentSnapPoint ? (
- {sheetContent}
- ) : (
- sheetContent
- )}
+ {sheetContent}
);
}
diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx
index d874a73618..c331f237c5 100644
--- a/packages/app/src/components/add-project-flow.tsx
+++ b/packages/app/src/components/add-project-flow.tsx
@@ -19,6 +19,7 @@ import {
useRef,
useState,
type ComponentType,
+ type RefObject,
} from "react";
import {
Modal,
@@ -71,6 +72,7 @@ import {
type ProjectPickerOption,
} from "@/components/project-picker-options";
import { Shortcut } from "@/components/ui/shortcut";
+import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
@@ -252,7 +254,17 @@ function pathTestId(path: string): string {
return `add-project-flow-path-${encodeURIComponent(path)}`;
}
-function FlowRow({ option, active }: { option: FlowRowOption; active: boolean }) {
+function FlowRow({
+ option,
+ active,
+ activeItemRef,
+ onRowLayout,
+}: {
+ option: FlowRowOption;
+ active: boolean;
+ activeItemRef: RefObject;
+ onRowLayout: () => void;
+}) {
const accessibilityState = useMemo(
() => ({ disabled: option.disabled === true, selected: active }),
[active, option.disabled],
@@ -270,6 +282,8 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={option.disabled}
+ ref={active ? activeItemRef : undefined}
+ onLayout={active ? onRowLayout : undefined}
onPress={option.select}
style={rowStyle}
testID={option.testID}
@@ -384,6 +398,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef(null);
+ const resultsRef = useRef(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
@@ -715,6 +730,13 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
]);
const activeIndex = rows.length === 0 ? 0 : Math.min(page.activeIndex, rows.length - 1);
+ const rowListKey = useMemo(() => rows.map((row) => row.id).join("\u0000"), [rows]);
+ const { activeItemRef, contentRef, onItemLayout, onViewportLayout, onScroll } =
+ useRevealActiveItem({
+ activeIndex,
+ listKey: rowListKey,
+ scrollRef: resultsRef,
+ });
const createDirectory = useCallback(async () => {
if (page.kind !== "new-directory-name" || !client) return;
const name = page.name.trim();
@@ -905,53 +927,64 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null}
- {preview ? (
-
- {shortenPath(preview)}
-
- ) : null}
- {isSubmitting ? (
-
- {progressText(page)}
-
- ) : null}
- {!isSubmitting && page.error ? (
-
- {page.error}
-
- ) : null}
- {!isSubmitting && queryError ? (
-
- {queryError}
-
- ) : null}
- {!isSubmitting && loading ? (
-
- Loading...
-
- ) : null}
- {!isSubmitting &&
- (!loading || page.kind === "github-search") &&
- (!queryError || page.kind === "github-search")
- ? rows.map((option, index) => (
-
- ))
- : null}
- {!isSubmitting &&
- !loading &&
- !queryError &&
- rows.length === 0 &&
- page.kind !== "new-directory-name" ? (
-
- {emptyText(page, host ?? null)}
-
- ) : null}
+
+ {preview ? (
+
+ {shortenPath(preview)}
+
+ ) : null}
+ {isSubmitting ? (
+
+ {progressText(page)}
+
+ ) : null}
+ {!isSubmitting && page.error ? (
+
+ {page.error}
+
+ ) : null}
+ {!isSubmitting && queryError ? (
+
+ {queryError}
+
+ ) : null}
+ {!isSubmitting && loading ? (
+
+ Loading...
+
+ ) : null}
+ {!isSubmitting &&
+ (!loading || page.kind === "github-search") &&
+ (!queryError || page.kind === "github-search")
+ ? rows.map((option, index) => (
+
+ ))
+ : null}
+ {!isSubmitting &&
+ !loading &&
+ !queryError &&
+ rows.length === 0 &&
+ page.kind !== "new-directory-name" ? (
+
+ {emptyText(page, host ?? null)}
+
+ ) : null}
+
diff --git a/packages/app/src/components/assistant-turn-footer-label.test.ts b/packages/app/src/components/assistant-turn-footer-label.test.ts
new file mode 100644
index 0000000000..7e3e918df8
--- /dev/null
+++ b/packages/app/src/components/assistant-turn-footer-label.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import { i18n } from "@/i18n/i18next";
+import { getTurnDurationLabel } from "./assistant-turn-footer-label";
+
+describe("getTurnDurationLabel", () => {
+ it("says how long the turn worked", () => {
+ expect(getTurnDurationLabel(14_000, i18n.t)).toBe("Worked for 14s");
+ });
+
+ it("reads in the active app language", async () => {
+ await i18n.changeLanguage("fr");
+ try {
+ expect(getTurnDurationLabel(14_000, i18n.t)).toBe("A travaillé pendant 14s");
+ } finally {
+ await i18n.changeLanguage("en");
+ }
+ });
+});
diff --git a/packages/app/src/components/assistant-turn-footer-label.ts b/packages/app/src/components/assistant-turn-footer-label.ts
new file mode 100644
index 0000000000..2e2a088f0c
--- /dev/null
+++ b/packages/app/src/components/assistant-turn-footer-label.ts
@@ -0,0 +1,6 @@
+import type { TFunction } from "i18next";
+import { formatDuration } from "@/utils/time";
+
+export function getTurnDurationLabel(durationMs: number, t: TFunction): string {
+ return t("message.turnFooter.workedFor", { duration: formatDuration(durationMs) });
+}
diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx
new file mode 100644
index 0000000000..6e4cd525f1
--- /dev/null
+++ b/packages/app/src/components/floating-header/index.tsx
@@ -0,0 +1,73 @@
+import { useCallback, useState, type ReactNode } from "react";
+import { View, type LayoutChangeEvent } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { EdgeFade } from "@/components/ui/edge-fade";
+
+/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
+const FADE_OVERHANG = 48;
+/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
+const FADE_SOLID_FRACTION = 0.57;
+
+interface FloatingHeaderLayoutProps {
+ header: ReactNode;
+ /** Float only when the content supports visual-top clearance. */
+ floating: boolean;
+ children: (topOverlayInset: number) => ReactNode;
+}
+
+/**
+ * Stacks a header above content, or floats it over the content behind a fade. Children keep the
+ * same tree position in both modes, so toggling `floating` never remounts them.
+ */
+export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
+ const [headerHeight, setHeaderHeight] = useState(0);
+ const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
+ setHeaderHeight(Math.round(event.nativeEvent.layout.height));
+ }, []);
+
+ return (
+
+ {floating ? null : header}
+ {children(floating ? headerHeight : 0)}
+ {floating ? (
+
+
+
+ {header}
+
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ minHeight: 0,
+ },
+ content: {
+ flex: 1,
+ minHeight: 0,
+ },
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ // The fade's tail lives inside the overlay so Android does not clip it.
+ paddingBottom: FADE_OVERHANG,
+ },
+ fade: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx
index 6532ee561a..a9398da0cf 100644
--- a/packages/app/src/components/headers/back-header.tsx
+++ b/packages/app/src/components/headers/back-header.tsx
@@ -6,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
+import { CompactHeader } from "./compact-header";
+import { useIsCompactFormFactor } from "@/constants/layout";
interface BackHeaderProps {
title?: string;
- titleAccessory?: ReactNode;
rightContent?: ReactNode;
onBack?: () => void;
}
@@ -18,7 +19,15 @@ function goBack(): void {
router.back();
}
-export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
+export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
+ const isCompact = useIsCompactFormFactor();
+ if (isCompact) {
+ return ;
+ }
+ return ;
+}
+
+function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
@@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
{title && {title} }
- {titleAccessory}
>
}
right={rightContent}
@@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
backButton: {
- padding: {
- xs: theme.spacing[3],
- md: theme.spacing[2],
- },
+ padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
}));
diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx
new file mode 100644
index 0000000000..6ed2bddcbe
--- /dev/null
+++ b/packages/app/src/components/headers/compact-header.tsx
@@ -0,0 +1,175 @@
+import { useCallback, useMemo, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Text, View } from "react-native";
+import { router } from "expo-router";
+import { ArrowLeft } from "lucide-react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
+import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
+import {
+ IconButtonChromeProvider,
+ iconButtonChromeGlyphSize,
+ mutedIconColorMapping,
+} from "@/components/ui/icon-button-chrome";
+import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
+import type { ShortcutKey } from "@/utils/format-shortcut";
+
+const ThemedArrowLeft = withUnistyles(ArrowLeft);
+
+const NO_SHORTCUT: ShortcutKey[] = [];
+
+interface CompactHeaderProps {
+ /** The leading island: the sidebar menu, or a back button. */
+ navigation: "menu" | "back";
+ /** Back button action. Defaults to navigating back. */
+ onBack?: () => void;
+ title?: string;
+ titleTestID?: string;
+ /** A line under the title. */
+ subtitle?: ReactNode;
+ /** Show a placeholder instead of the title while it loads. */
+ loading?: boolean;
+ /** Icon actions inherit the island chrome through the shared button primitives. */
+ actions?: ReactNode;
+}
+
+/**
+ * The phone header: the navigation button and the screen's actions sit in islands, with the title
+ * left-aligned between them. Neither the header nor its islands draw a background, so it reads the
+ * same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
+ * contrast). Its height matches the old flat header, so anything
+ * offset by the header height still lines up.
+ */
+export function CompactHeader({
+ navigation,
+ onBack,
+ title,
+ titleTestID,
+ subtitle,
+ loading = false,
+ actions,
+}: CompactHeaderProps) {
+ const insets = useSafeAreaInsets();
+ const rootStyle = useMemo(
+ () => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
+ [insets.top],
+ );
+
+ return (
+
+
+
+
+ {navigation === "menu" ? : }
+
+
+
+
+ {actions ? {actions} : null}
+
+
+
+ );
+}
+
+function CompactHeaderTitle({
+ loading,
+ title,
+ titleTestID,
+ subtitle,
+}: Pick & { loading: boolean }) {
+ if (loading) {
+ return ;
+ }
+ return (
+ <>
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {subtitle}
+ >
+ );
+}
+
+function BackButton({ onBack }: { onBack?: () => void }) {
+ const { t } = useTranslation();
+ const handlePress = useCallback(() => {
+ if (onBack) {
+ onBack();
+ return;
+ }
+ router.back();
+ }, [onBack]);
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ root: {
+ paddingHorizontal: theme.spacing[3],
+ },
+ row: {
+ height: HEADER_INNER_HEIGHT_MOBILE,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ titleGroup: {
+ flex: 1,
+ minWidth: 0,
+ // Both title lines receive the remaining width; text alignment does not size the slot.
+ alignItems: "stretch",
+ },
+ island: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ title: {
+ fontSize: theme.fontSize.base,
+ fontWeight: theme.fontWeight.medium,
+ color: theme.colors.foreground,
+ textAlign: "left",
+ },
+ // A title without a subtitle has the header to itself, so it reads one step larger.
+ singleLineTitle: {
+ fontSize: theme.fontSize.lg,
+ fontWeight: theme.fontWeight.medium,
+ color: theme.colors.foreground,
+ textAlign: "left",
+ },
+ titleSkeleton: {
+ width: 140,
+ maxWidth: "100%",
+ height: 22,
+ borderRadius: theme.borderRadius.full,
+ backgroundColor: theme.colors.surface3,
+ opacity: 0.25,
+ },
+}));
diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx
index b11394c46d..137d5d483d 100644
--- a/packages/app/src/components/headers/header-toggle-button.tsx
+++ b/packages/app/src/components/headers/header-toggle-button.tsx
@@ -1,4 +1,4 @@
-import { useMemo, type ReactElement, type ReactNode } from "react";
+import { type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
- iconButtonChromeStyle,
+ useIconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
@@ -46,17 +46,11 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record)
: null;
- const combinedStyle = useMemo(
- () =>
- ({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
- iconButtonChromeStyle({
- size: "large",
- state: { hovered: Boolean(hovered), pressed },
- disabled: Boolean(disabled),
- style,
- }),
- [disabled, style],
- );
+ const combinedStyle = useIconButtonChromeStyle({
+ size: "large",
+ disabled: Boolean(disabled),
+ style,
+ });
return (
diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx
index 37f39ca0ba..fc4f2442ff 100644
--- a/packages/app/src/components/headers/menu-header.tsx
+++ b/packages/app/src/components/headers/menu-header.tsx
@@ -1,16 +1,10 @@
-import { useCallback, useMemo, type ReactNode } from "react";
-import { useTranslation } from "react-i18next";
-import { View, type StyleProp, type ViewStyle } from "react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { PanelLeft } from "lucide-react-native";
+import type { ReactNode } from "react";
+import { StyleSheet } from "react-native-unistyles";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
-import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
-import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
+import { CompactHeader } from "./compact-header";
+import { SidebarMenuToggle } from "./sidebar-menu-toggle";
import { useIsCompactFormFactor } from "@/constants/layout";
-import { getShortcutOs } from "@/utils/shortcut-platform";
-import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
-import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -18,130 +12,11 @@ interface MenuHeaderProps {
borderless?: boolean;
}
-interface SidebarMenuToggleProps {
- style?: StyleProp;
- tooltipSide?: "left" | "right" | "top" | "bottom";
- testID?: string;
- nativeID?: string;
-}
-
-const MOBILE_MENU_LINE_WIDTH = 16;
-const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
-const MOBILE_MENU_LINE_HEIGHT = 1.5;
-
-function MobileMenuIcon({ color }: { color: string }) {
- const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
- const shortLineStyle = useMemo(
- () => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }],
- [color],
- );
- return (
-
-
-
-
-
- );
-}
-
-function SidebarMenuToggleButton({
- isMobile,
- extraMutedIdleIcon = false,
- resolvedStyle,
- tooltipSide = "right",
- testID = "menu-button",
- nativeID = "menu-button",
-}: Omit & {
- isMobile: boolean;
- extraMutedIdleIcon?: boolean;
- resolvedStyle: StyleProp;
-}) {
- const { theme } = useUnistyles();
- const { t } = useTranslation();
- const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
- const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
- const toggleShortcutKeys = useMemo(
- () => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
- [],
- );
-
- const handlePress = useCallback(() => {
- toggleAgentListForLayout({ isCompact: isMobile });
- }, [toggleAgentListForLayout, isMobile]);
-
- const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
-
- return (
-
- {isMobile ? (
-
- ) : (
-
- )}
-
- );
-}
-
-export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
- const isMobile = useIsCompactFormFactor();
- const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
- const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
- const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
- const placeholderStyle = useMemo(
- () => [headerIconSlotStyle.slot, resolvedStyle],
- [resolvedStyle],
- );
-
- if (!isMobile && !ownsTopLeft) {
- return null;
- }
-
- if (!isMobile && hasTopLeftWindowControls) {
- return (
-
-
-
- );
- }
-
- return ;
-}
-
-export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
- const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
- return (
-
- );
-}
-
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
+ const isCompact = useIsCompactFormFactor();
+ if (isCompact) {
+ return ;
+ }
return (
({
- leadingToggle: {
- marginLeft: {
- xs: 0,
- md: -theme.spacing[2],
- },
- },
left: {
gap: theme.spacing[2],
},
- mobileMenuIcon: {
- width: MOBILE_MENU_LINE_WIDTH,
- height: 12,
- justifyContent: "space-between",
- alignItems: "flex-start",
- },
- desktopMenuIconSpace: {
- width: theme.iconSize.md,
- height: theme.iconSize.md,
- },
- mobileMenuLine: {
- width: MOBILE_MENU_LINE_WIDTH,
- height: MOBILE_MENU_LINE_HEIGHT,
- borderRadius: theme.borderRadius.full,
- },
- mobileMenuLineShort: {
- width: MOBILE_MENU_LINE_SHORT_WIDTH,
- },
}));
diff --git a/packages/app/src/components/headers/sidebar-menu-toggle.tsx b/packages/app/src/components/headers/sidebar-menu-toggle.tsx
new file mode 100644
index 0000000000..445b7baa96
--- /dev/null
+++ b/packages/app/src/components/headers/sidebar-menu-toggle.tsx
@@ -0,0 +1,167 @@
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { View, type StyleProp, type ViewStyle } from "react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { PanelLeft } from "lucide-react-native";
+import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
+import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import { getShortcutOs } from "@/utils/shortcut-platform";
+import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
+import {
+ extraMutedIconColorMapping,
+ iconButtonChromeGlyphSize,
+ mutedIconColorMapping,
+} from "@/components/ui/icon-button-chrome";
+
+const ThemedPanelLeft = withUnistyles(PanelLeft);
+
+interface SidebarMenuToggleProps {
+ style?: StyleProp;
+ tooltipSide?: "left" | "right" | "top" | "bottom";
+ testID?: string;
+ nativeID?: string;
+}
+
+const MOBILE_MENU_LINE_WIDTH = 16;
+const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
+const MOBILE_MENU_LINE_HEIGHT = 1.5;
+
+function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) {
+ const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted;
+ const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]);
+ const shortLineStyle = useMemo(
+ () => [styles.mobileMenuLine, styles.mobileMenuLineShort, color],
+ [color],
+ );
+ return (
+
+
+
+
+
+ );
+}
+
+function SidebarMenuToggleButton({
+ isMobile,
+ extraMutedIdleIcon = false,
+ resolvedStyle,
+ tooltipSide = "right",
+ testID = "menu-button",
+ nativeID = "menu-button",
+}: Omit & {
+ isMobile: boolean;
+ extraMutedIdleIcon?: boolean;
+ resolvedStyle: StyleProp;
+}) {
+ const { t } = useTranslation();
+ const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
+ const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
+ const toggleShortcutKeys = useMemo(
+ () => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
+ [],
+ );
+
+ const handlePress = useCallback(() => {
+ toggleAgentListForLayout({ isCompact: isMobile });
+ }, [toggleAgentListForLayout, isMobile]);
+
+ const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
+
+ return (
+
+ {isMobile ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
+ const isMobile = useIsCompactFormFactor();
+ const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
+ const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
+ const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
+ const placeholderStyle = useMemo(
+ () => [headerIconSlotStyle.slot, resolvedStyle],
+ [resolvedStyle],
+ );
+
+ if (!isMobile && !ownsTopLeft) {
+ return null;
+ }
+
+ if (!isMobile && hasTopLeftWindowControls) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
+
+export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
+ const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ leadingToggle: {
+ marginLeft: {
+ xs: 0,
+ md: -theme.spacing[2],
+ },
+ },
+ mobileMenuIcon: {
+ width: MOBILE_MENU_LINE_WIDTH,
+ height: 12,
+ justifyContent: "space-between",
+ alignItems: "flex-start",
+ },
+ desktopMenuIconSpace: {
+ width: theme.iconSize.md,
+ height: theme.iconSize.md,
+ },
+ mobileMenuLine: {
+ width: MOBILE_MENU_LINE_WIDTH,
+ height: MOBILE_MENU_LINE_HEIGHT,
+ borderRadius: theme.borderRadius.full,
+ },
+ mobileMenuLineMuted: {
+ backgroundColor: theme.colors.foregroundMuted,
+ },
+ mobileMenuLineExtraMuted: {
+ backgroundColor: theme.colors.foregroundExtraMuted,
+ },
+ mobileMenuLineShort: {
+ width: MOBILE_MENU_LINE_SHORT_WIDTH,
+ },
+}));
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index ddfc9e219f..f054668347 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -1,5 +1,13 @@
import { router } from "expo-router";
-import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
+import {
+ CircleGauge,
+ FolderPlus,
+ GitBranch,
+ Server,
+ Settings,
+ X,
+ type LucideIcon,
+} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -26,7 +34,8 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
-import { buttonControlHeight } from "@/components/ui/control-geometry";
+import { DensityIcon } from "@/components/ui/density-icon";
+import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -37,6 +46,7 @@ import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
+import { density } from "@/styles/density";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -280,30 +290,25 @@ function FooterIconButton({
onPress,
testID,
label,
- icon: Icon,
+ icon,
iconSizeAdjustment = 0,
shortcutKeys,
- theme,
}: {
onPress: () => void;
testID: string;
label: string;
- icon: typeof FolderPlus;
+ icon: LucideIcon;
/** Only for a glyph that reads larger than the others at the same size. */
iconSizeAdjustment?: number;
shortcutKeys?: ReturnType;
- theme: SidebarTheme;
buttonRef?: RefObject;
}) {
- const isCompact = useIsCompactFormFactor();
- const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
-
return (
{({ hovered }) => (
-
)}
@@ -328,12 +335,10 @@ function FooterIconButton({
}
function SidebarHostPicker({
- theme,
label,
onAddHost,
onOpenHostSettings,
}: {
- theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
@@ -377,7 +382,6 @@ function SidebarHostPicker({
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1}
- theme={theme}
/>
);
@@ -399,7 +403,6 @@ function IconTooltipContent({
}
function SidebarFooter({
- theme,
handleOpenProject,
handleSettings,
labels,
@@ -407,7 +410,6 @@ function SidebarFooter({
handleOpenHostSettings,
onBeforeNavigate,
}: {
- theme: SidebarTheme;
handleOpenProject: () => void;
handleSettings: () => void;
labels: {
@@ -436,11 +438,9 @@ function SidebarFooter({
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
- theme={theme}
/>
-
+
@@ -461,7 +460,7 @@ function SidebarFooter({
);
}
-function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
+function SidebarUsageIcon({ label }: { label: string }) {
const openUsage = useOpenSidebarUsage();
return (
);
}
@@ -612,7 +610,6 @@ function MobileSidebar({
)}
- Workspaces
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -855,29 +848,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
- workspacesSectionHeader: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- gap: theme.spacing[2],
- // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
- // lands at spacing[2] left to align with project icons. Settings2's painted path stops
- // inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
- paddingLeft: theme.spacing[2],
- paddingRight: 4,
- paddingTop: theme.spacing[1],
- paddingBottom: theme.spacing[1],
- },
- workspacesSectionTitle: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.normal,
- },
- workspacesSectionActions: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- },
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -957,14 +927,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
- footerIconButton: (isCompact: boolean) => ({
- width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
- height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
+ footerIconButton: {
+ width: density.iconButton,
+ height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
- }),
+ },
tooltipRow: {
flexDirection: "row",
alignItems: "center",
diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts
index 394ea47ed8..f8606d2714 100644
--- a/packages/app/src/components/markdown/html-ish.test.ts
+++ b/packages/app/src/components/markdown/html-ish.test.ts
@@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
+ it("renders heading text beside a linked picture without literal heading tags", () => {
+ const source =
+ ' Confidence Score: 5/5 ';
+
+ expect(splitHtmlishMarkdown(source)).toEqual([
+ {
+ kind: "inlineImage",
+ alt: "Retrigger",
+ src: "https://example.com/l.svg",
+ href: "https://example.com/r",
+ flowsWithText: true,
+ },
+ { kind: "markdown", text: "Confidence Score: 5/5" },
+ ]);
+ });
+
+ it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
+ "keeps a mixed %s heading together after preceding markdown",
+ (heading) => {
+ const source = `Intro\n\n<${heading}> Caption${heading}>\n\nOutro`;
+
+ expect(splitHtmlishMarkdown(source)).toEqual([
+ { kind: "markdown", text: "Intro\n\n" },
+ {
+ kind: "inlineImage",
+ alt: "Icon",
+ src: "https://example.com/icon.svg",
+ flowsWithText: true,
+ },
+ { kind: "markdown", text: "Caption\n\nOutro" },
+ ]);
+ },
+ );
+
+ it("preserves formatted text before and after images in a details heading", () => {
+ const source =
+ 'More Before After ';
+
+ expect(splitHtmlishMarkdown(source)).toEqual([
+ {
+ kind: "details",
+ summary: "More",
+ body: "**Before***After*",
+ bodyParts: [
+ { kind: "markdown", text: "**Before**" },
+ { kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
+ { kind: "markdown", text: "*After*" },
+ ],
+ },
+ ]);
+ });
+
+ it("keeps mixed HTML headings literal in code examples", () => {
+ const heading = ' Caption ';
+ const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
+
+ expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
+ });
+
+ it("preserves unknown wrappers around text-only headings", () => {
+ expect(splitHtmlishMarkdown("")).toEqual([
+ { kind: "markdown", text: "" },
+ ]);
+ });
+
const inlineImageBody = [
' Spoofed browser User-Agent allows access control bypass',
"",
@@ -391,6 +456,43 @@ describe("splitHtmlishMarkdown", () => {
);
});
+ it("keeps table rows after a comparison that only looks like a tag", () => {
+ const source = [
+ "| ID | Note | Status |",
+ "|---|---|---|",
+ "| a-1 | accepts 1 y.",
+ ].join("\n");
+
+ expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
+ });
+
+ it.each(["See for more.", "Stop when a and a {
+ expect(normalizeHtmlishMarkdown(source)).toBe(source);
+ },
+ );
+
+ it.each([
+ "Call As() before limits::max()",
+ "Ctrl then ",
+ ])("echoes unknown tags in %j as written", (source) => {
+ expect(normalizeHtmlishMarkdown(source)).toBe(source);
+ });
+
+ it("rebuilds an unknown tag that a code span splits", () => {
+ expect(normalizeHtmlishMarkdown('hi ')).toBe("`x`hi ");
+ });
+
+ it("still reads tags and comments next to literal angle brackets", () => {
+ expect(normalizeHtmlishMarkdown("1bold 3<4")).toBe(
+ "1 {
const source = "Open Still open";
diff --git a/packages/app/src/components/markdown/html-ish.ts b/packages/app/src/components/markdown/html-ish.ts
index 346a0dfc13..94eb7cab5b 100644
--- a/packages/app/src/components/markdown/html-ish.ts
+++ b/packages/app/src/components/markdown/html-ish.ts
@@ -29,6 +29,12 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
+// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
+const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
+const HTML_TAG_RE = new RegExp(
+ String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
+ "y",
+);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly> = {
b: ["**", "**"],
@@ -119,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
- const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
- const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
- const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
+ if (isHeadingTag(token)) {
+ const closeIndex = findMixedHeadingClose(tokens, cursor);
+ if (closeIndex !== null) {
+ // Headings render as their contents. Split those contents together so an
+ // embedded image cannot strand the opening heading tag in a text slice.
+ for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
+ appendDisplayPart(parts, part);
+ }
+ cursor = closeIndex + 1;
+ continue;
+ }
+ }
+
+ const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -253,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
-function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
+function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
- if (parseInlineImageAt(tokens, index)) {
+ if (
+ isOpenTag(tokens[index], "details") ||
+ findMixedHeadingClose(tokens, index) !== null ||
+ parseInlineImageAt(tokens, index)
+ ) {
return index;
}
}
return null;
}
+function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
+ const token = tokens[start];
+ if (!isHeadingTag(token)) {
+ return null;
+ }
+ const closeIndex = findMatchingClose(tokens, start, token.name);
+ if (closeIndex === null) {
+ return null;
+ }
+ for (let index = start + 1; index < closeIndex; index += 1) {
+ if (parseInlineImageAt(tokens, index)) {
+ return closeIndex;
+ }
+ }
+ return null;
+}
+
+function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
+ if (part.kind === "markdown") {
+ appendMarkdownPart(parts, part.text);
+ } else {
+ parts.push(part);
+ }
+}
+
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
@@ -373,14 +419,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
- output += renderHtmlTag(token, children);
+ const close = tokens[closeIndex];
+ const closeRaw = isClosingTag(close, token.name) ? close.raw : `${token.name}>`;
+ output += renderHtmlTag(token, children, closeRaw);
index = closeIndex;
}
return output;
}
-function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
+function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
@@ -400,7 +448,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
- return `${token.raw}${renderInlineTokens(children)}${token.name}>`;
+ return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
@@ -606,7 +654,15 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
let stripNextLeadingLineBreak = false;
let skippedLength = 0;
+ let writtenLength = 0;
+ let chunkStart = 0;
let parser!: Parser;
+ // Unknown tags render as text, so keep them as written: the parser lowercases names
+ // and rewrites attributes. A tag split by a protected range has no single slice.
+ const readRawTag = (): string | null =>
+ parser.startIndex < chunkStart
+ ? null
+ : source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
parser = new Parser(
{
onopentag(name, attributes, isImplied) {
@@ -620,7 +676,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: false,
selfClosing,
attributes,
- raw: renderStartTag(name, attributes),
+ raw: readRawTag() ?? renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
@@ -633,7 +689,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: true,
selfClosing: false,
attributes: {},
- raw: `${name}>`,
+ raw: readRawTag() ?? `${name}>`,
});
},
ontext(value) {
@@ -660,6 +716,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
);
return {
write(chunk) {
+ chunkStart = writtenLength;
+ writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
@@ -698,7 +756,58 @@ function escapeAttribute(value: string): string {
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
- return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
+ const codeRanges = mergeProtectedRanges([
+ ...fencedRanges,
+ ...getInlineCodeRanges(source, fencedRanges),
+ ]);
+ return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
+}
+
+/**
+ * htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
+ * to the next `>`, so `1", index + 2);
+ commentClose = close === -1 ? source.length : close;
+ }
+ htmlEnd = commentClose === source.length ? null : commentClose + 3;
+ } else {
+ HTML_TAG_RE.lastIndex = index;
+ htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
+ }
+
+ if (htmlEnd === null) {
+ ranges.push({ start: index, end: index + 1 });
+ htmlEnd = index + 1;
+ }
+ index = source.indexOf("<", htmlEnd);
+ }
+
+ return ranges;
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 1bbeca891f..7915832470 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -1,3 +1,4 @@
+import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { TaskListRow } from "@/components/task-list-row";
@@ -74,6 +75,7 @@ import { useRevealedText } from "@/hooks/use-revealed-text";
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
+import { getTurnDurationLabel } from "./assistant-turn-footer-label";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
@@ -93,6 +95,7 @@ import {
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
+import { localFileSourceToPath } from "@/attachments/utils";
import {
AttachmentFrame,
AttachmentLabel,
@@ -629,6 +632,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
durationMs,
onFork,
}: AssistantTurnFooterProps) {
+ const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false);
const revealTimerRef = useRef | null>(null);
@@ -644,10 +648,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
const durationLabel = useMemo(
() =>
- durationMs !== undefined && durationMs !== null
- ? `Worked for ${formatDuration(durationMs)}`
- : "",
- [durationMs],
+ durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
+ [durationMs, t],
);
const timestampLabel = useMemo(
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
@@ -1944,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
buildToolCallPresentation({
+ agentMessage,
toolName,
status,
error: error ?? null,
@@ -3089,7 +3094,7 @@ export const ToolCall = memo(function ToolCall({
cwd,
resolveIcon: resolveToolCallIcon,
}),
- [toolName, status, error, effectiveDetail, metadata, cwd],
+ [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
);
const handleOpenFile = useMemo(() => {
const openFilePath = presentation.openFilePath;
@@ -3105,7 +3110,7 @@ export const ToolCall = memo(function ToolCall({
toolName,
displayName: presentation.displayName,
summary: presentation.summary,
- detail: effectiveDetail,
+ detail: presentation.detail,
errorText: presentation.errorText,
icon: presentation.icon,
showLoadingSkeleton: presentation.isLoadingDetails,
@@ -3117,12 +3122,12 @@ export const ToolCall = memo(function ToolCall({
shouldRenderInline,
openToolCall,
toolName,
+ presentation.detail,
presentation.displayName,
presentation.summary,
presentation.errorText,
presentation.icon,
presentation.isLoadingDetails,
- effectiveDetail,
]);
useEffect(() => {
@@ -3158,7 +3163,7 @@ export const ToolCall = memo(function ToolCall({
return (
Promise | void;
}
-const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24;
+const TILE_GAP = SPACING[3];
+/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
+const MIN_TILE_WIDTH = 300;
+const MAX_COLUMNS = 4;
const ThemedPackagePlus = withUnistyles(PackagePlus);
const ThemedSvgXml = withUnistyles(SvgXml);
-const ThemedSearch = withUnistyles(Search);
-const ThemedExternalLink = withUnistyles(ExternalLink);
-const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
- placeholderTextColor: theme.colors.foregroundMuted,
-}));
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
-const foregroundMutedColorMapping = (theme: Theme) => ({
- color: theme.colors.foregroundMuted,
-});
-function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
- const normalized = query.trim().toLowerCase();
- if (!normalized) return true;
- return [entry.title, entry.id, entry.description].some((value) =>
- value.toLowerCase().includes(normalized),
- );
+function columnCountForWidth(width: number): number {
+ const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP));
+ return Math.min(MAX_COLUMNS, Math.max(1, fits));
}
-interface CatalogRowProps {
+interface CatalogTileProps {
entry: AcpProviderCatalogItem;
+ /** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
+ width: number | undefined;
installing: boolean;
onInstall: (entry: AcpProviderCatalogItem) => void;
}
-function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
+function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) {
const { t } = useTranslation();
- const actionLabel = installing
- ? t("providerCatalog.actions.adding")
- : t("providerCatalog.actions.add");
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
-
- const handleOpenInstallLink = useCallback(() => {
- void openExternalUrl(entry.installLink);
- }, [entry.installLink]);
+ const tileStyle = useMemo(
+ () => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
+ [width],
+ );
return (
-
+
{entry.iconSvg ? (
)}
-
-
-
- {entry.title}
-
-
- {entry.version}
-
-
-
- {entry.description || entry.id}
+
+
+ {entry.title}
-
-
- {t("providerCatalog.actions.installInstructions")}
-
-
-
+
+ {/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
+
+
+
- {actionLabel}
+ {installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
);
@@ -129,6 +113,7 @@ export function ProviderCatalogList({
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState("");
+ const [gridWidth, setGridWidth] = useState(null);
const installedIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
@@ -137,41 +122,48 @@ export function ProviderCatalogList({
const availableEntries = useMemo(
() =>
- catalogEntries
- .filter((entry) => !installedIds.has(entry.id))
- .filter((entry) => matchesSearch(entry, search)),
+ rankProviderCatalog(
+ catalogEntries.filter((entry) => !installedIds.has(entry.id)),
+ search,
+ ),
[catalogEntries, installedIds, search],
);
+ const handleGridLayout = useCallback((event: LayoutChangeEvent) => {
+ setGridWidth(event.nativeEvent.layout.width);
+ }, []);
+
+ const tileWidth = useMemo(() => {
+ if (gridWidth === null) return undefined;
+ const columns = columnCountForWidth(gridWidth);
+ if (columns === 1) return undefined;
+ return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns);
+ }, [gridWidth]);
+
return (
-
-
-
-
-
+
{availableEntries.length === 0 ? (
-
+
{t("providerCatalog.noProviders")}
) : (
-
+
{availableEntries.map((entry) => (
-
@@ -183,101 +175,56 @@ export function ProviderCatalogList({
}
const styles = StyleSheet.create((theme) => ({
- searchField: {
+ searchRail: {
flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- backgroundColor: theme.colors.surface2,
- borderRadius: theme.borderRadius.lg,
- borderWidth: 1,
- borderColor: theme.colors.border,
- paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[3],
},
- searchIcon: {
- width: 18,
- alignItems: "center",
+ grid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: TILE_GAP,
},
- searchInput: {
- flex: 1,
- minWidth: 0,
- paddingVertical: theme.spacing[3],
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
- },
- list: {
- borderRadius: theme.borderRadius.lg,
- borderWidth: 1,
- borderColor: theme.colors.border,
- overflow: "hidden",
- },
- row: {
+ tile: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
padding: theme.spacing[3],
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.border,
+ },
+ tileFull: {
+ width: "100%",
+ },
+ details: {
+ flex: 1,
+ minWidth: 0,
+ gap: theme.spacing[1],
},
iconFrame: {
+ flexShrink: 0,
width: 36,
height: 36,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
+ },
+ addButton: {
flexShrink: 0,
},
- textColumn: {
- flex: 1,
- minWidth: 0,
- gap: theme.spacing[1],
- },
- titleRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- minWidth: 0,
- },
name: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.medium,
- flexShrink: 1,
+ fontWeight: theme.fontWeight.normal,
},
- version: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- flexShrink: 0,
- },
- description: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- installLink: {
- alignSelf: "flex-start",
+ metaRow: {
flexDirection: "row",
+ flexWrap: "wrap",
alignItems: "center",
- gap: theme.spacing[1],
- maxWidth: "100%",
- },
- installLinkText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- actionButton: {
- width: 92,
- flexShrink: 0,
+ gap: theme.spacing[2],
},
stateBox: {
minHeight: 96,
- borderRadius: theme.borderRadius.lg,
- borderWidth: 1,
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
- gap: theme.spacing[3],
padding: theme.spacing[4],
},
stateText: {
diff --git a/packages/app/src/components/provider-catalog-search.test.ts b/packages/app/src/components/provider-catalog-search.test.ts
new file mode 100644
index 0000000000..4079d79602
--- /dev/null
+++ b/packages/app/src/components/provider-catalog-search.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { rankProviderCatalog } from "./provider-catalog-search";
+
+const entries = [
+ { id: "kiro-cli", title: "Kiro CLI" },
+ { id: "goose", title: "goose" },
+ { id: "kimi", title: "Kimi Code CLI" },
+ { id: "gemini", title: "Gemini CLI" },
+ { id: "qwen-code", title: "Qwen Code" },
+ { id: "codex-acp", title: "Codex" },
+];
+
+function titles(list: { title: string }[]): string[] {
+ return list.map((entry) => entry.title);
+}
+
+describe("rankProviderCatalog", () => {
+ it("lists every provider alphabetically by name without a query", () => {
+ expect(titles(rankProviderCatalog(entries, " "))).toEqual([
+ "Codex",
+ "Gemini CLI",
+ "goose",
+ "Kimi Code CLI",
+ "Kiro CLI",
+ "Qwen Code",
+ ]);
+ });
+
+ it("ranks the strongest match first and drops providers that do not match", () => {
+ expect(titles(rankProviderCatalog(entries, "cod"))).toEqual([
+ "Codex",
+ "Kimi Code CLI",
+ "Qwen Code",
+ ]);
+ });
+
+ it("matches in-word subsequences and multiple tokens like the other pickers", () => {
+ expect(titles(rankProviderCatalog(entries, "gmni"))).toEqual(["Gemini CLI"]);
+ expect(titles(rankProviderCatalog(entries, "qw cod"))).toEqual(["Qwen Code"]);
+ });
+
+ it("matches the provider id", () => {
+ expect(titles(rankProviderCatalog(entries, "kiro-cli"))).toEqual(["Kiro CLI"]);
+ });
+});
diff --git a/packages/app/src/components/provider-catalog-search.ts b/packages/app/src/components/provider-catalog-search.ts
new file mode 100644
index 0000000000..23303c868f
--- /dev/null
+++ b/packages/app/src/components/provider-catalog-search.ts
@@ -0,0 +1,34 @@
+import {
+ compareMatchScores,
+ scoreTextFields,
+ type MatchScore,
+} from "@getpaseo/protocol/search/text-match";
+
+export interface SearchableCatalogEntry {
+ id: string;
+ title: string;
+}
+
+function compareTitles(a: SearchableCatalogEntry, b: SearchableCatalogEntry): number {
+ return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
+}
+
+/**
+ * Orders the Add provider catalog. Without a query the list is alphabetical by
+ * name. With a query it uses the pickers' ranked matcher over the fields the
+ * tile shows (name and id), best match first, ties alphabetical.
+ */
+export function rankProviderCatalog(
+ entries: readonly T[],
+ query: string,
+): T[] {
+ if (!query.trim()) return [...entries].sort(compareTitles);
+
+ const scored: { entry: T; score: MatchScore }[] = [];
+ for (const entry of entries) {
+ const score = scoreTextFields(query, [entry.title, entry.id]);
+ if (score) scored.push({ entry, score });
+ }
+ scored.sort((a, b) => compareMatchScores(a.score, b.score) || compareTitles(a.entry, b.entry));
+ return scored.map(({ entry }) => entry);
+}
diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx
index bf2d1c1b95..61d42882e8 100644
--- a/packages/app/src/components/provider-diagnostic-sheet.tsx
+++ b/packages/app/src/components/provider-diagnostic-sheet.tsx
@@ -95,11 +95,11 @@ function CustomModelRow({
return (
-
+
{model.label}
({
iconButton: {
width: 28,
height: 28,
+ flexShrink: 0,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
flexShrink: 0,
},
+ customModelText: {
+ flexShrink: 1,
+ minWidth: 0,
+ },
modelRowFiller: {
flex: 1,
},
diff --git a/packages/app/src/components/question-form-card-core.test.ts b/packages/app/src/components/question-form-card-core.test.ts
index 40692d4acf..293bf24ba8 100644
--- a/packages/app/src/components/question-form-card-core.test.ts
+++ b/packages/app/src/components/question-form-card-core.test.ts
@@ -147,4 +147,29 @@ describe("question form card core", () => {
Response: "custom",
});
});
+
+ test("keeps option previews so options can be compared", () => {
+ const questions = parseQuestionFormQuestions({
+ questions: [
+ {
+ question: "Which layout?",
+ header: "Layout",
+ options: [
+ {
+ label: "Tabs",
+ description: "One tab per section",
+ preview: "\n \n ",
+ },
+ { label: "Single page" },
+ ],
+ multiSelect: false,
+ },
+ ],
+ });
+
+ expect(questions?.[0]?.options).toEqual([
+ { label: "Tabs", description: "One tab per section", preview: "\n \n " },
+ { label: "Single page", description: undefined, preview: undefined },
+ ]);
+ });
});
diff --git a/packages/app/src/components/question-form-card-core.ts b/packages/app/src/components/question-form-card-core.ts
index 0dea6c706e..78007dba2e 100644
--- a/packages/app/src/components/question-form-card-core.ts
+++ b/packages/app/src/components/question-form-card-core.ts
@@ -1,6 +1,7 @@
export interface QuestionOption {
label: string;
description?: string;
+ preview?: string;
}
export interface QuestionFormQuestion {
@@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
options.push({
label: o.label,
description: typeof o.description === "string" ? o.description : undefined,
+ preview: readOptionalString(o, "preview"),
});
}
questions.push({
diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx
index 5b3b146f4e..655e01d210 100644
--- a/packages/app/src/components/question-form-card.tsx
+++ b/packages/app/src/components/question-form-card.tsx
@@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
+import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
@@ -29,6 +30,9 @@ interface QuestionFormCardProps {
}
const IS_WEB = isWeb;
+// Native previews are uncapped: inside the option's Pressable, Android never hands a
+// drag to the preview's nested scroll, so lines past a cap would be unreachable.
+const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
function getQuestionInputPlaceholder({
question,
@@ -133,6 +137,20 @@ function QuestionOptionRow({
{option.description ? (
{option.description}
) : null}
+ {option.preview ? (
+ // Shown for every option at once so they can be compared before picking.
+ // Long lines wrap, as in Claude Code, since most previews are prose.
+
+ {option.preview}
+
+ ) : null}
@@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
lineHeight: 20,
},
+ optionPreviewText: {
+ lineHeight: 20,
+ ...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
+ },
selectionControl: {
width: 18,
height: 18,
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 4275099cfd..c750121368 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -30,6 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { density, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -63,11 +64,7 @@ import {
type SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
-import {
- hasActiveSidebarLabelFilter,
- useSidebarViewStore,
- type SidebarGroupMode,
-} from "@/stores/sidebar-view-store";
+import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import {
ContextMenu,
@@ -96,7 +93,7 @@ import {
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
-import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
+import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
@@ -118,6 +115,7 @@ import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
+import { DensityIcon } from "@/components/ui/density-icon";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -826,9 +824,10 @@ function NewWorkspaceGhostRow({
{({ hovered, pressed }) => (
<>
-
state.getPinnedWorkspaceOrder);
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
- const hasActiveLabelFilter = useSidebarViewStore((state) =>
- hasActiveSidebarLabelFilter(state.labelFilter),
- );
const handlePinnedWorkspaceReorder = useCallback(
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
@@ -1953,11 +1949,10 @@ export function SidebarWorkspaceList({
// this whole subtree, which unmounted the header — and the header is where the display menu's
// trigger lives, so filtering the last row away closed the menu you were filtering from.
//
- // Only the label filter can get here. The project filter resolves against the projects it can
- // see and falls back to "all projects" when nothing matches, so it either keeps at least one
- // project or is not applied at all — it can narrow this list but never empty it.
- const sidebarFilterEmpty =
- hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
+ // Background visibility can also hide the last project. Keep the header mounted
+ // whenever a local filter empties an otherwise populated directory, so users can
+ // reach the display menu to reveal their work again.
+ const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0;
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
@@ -2119,6 +2114,7 @@ function ProjectModeList({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) {
+ const { t } = useTranslation();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef>>(
@@ -2418,7 +2414,12 @@ function ProjectModeList({
<>
{pinnedChats.length > 0 ? (
-
+
{pinnedCollapsed ? null : (
<>
({
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
// pressed fills stay the same box as every other row in the sidebar.
newWorkspaceGhostRow: {
- minHeight: 36,
+ minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[4],
@@ -2547,8 +2548,8 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it.
newWorkspaceGhostIconSlot: {
- width: theme.iconSize.md,
- height: theme.iconSize.md,
+ width: densityIconBox("md"),
+ height: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
@@ -2567,7 +2568,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectRow: {
position: "relative",
- minHeight: 36,
+ minHeight: density.rowHeight,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -2689,7 +2690,7 @@ const styles = StyleSheet.create((theme) => ({
right: theme.spacing[2],
},
workspaceRow: {
- minHeight: 36,
+ minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx
index 018c8cc37e..d62b3f9ff1 100644
--- a/packages/app/src/components/sidebar/display-preferences/menu.tsx
+++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx
@@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
sheetTitle={t("sidebar.display.heading")}
testID="sidebar-display-preferences-content"
>
+
+ {t("sidebar.display.showBackground")}
+
;
export interface SidebarDisplayPreferences {
+ showBackground: boolean;
+ toggleBackground: () => void;
grouping: SidebarGroupMode;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
@@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences {
* this for a value and set it; where it lands is this module's problem.
*/
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
+ const showBackground = useSidebarViewStore((state) => state.showBackground);
+ const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
@@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
return useMemo(
() => ({
+ showBackground,
+ toggleBackground,
grouping,
setGrouping,
titleSource: workspaceTitleSource,
@@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
clearLabelFilter,
}),
[
+ showBackground,
+ toggleBackground,
grouping,
setGrouping,
workspaceTitleSource,
diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx
deleted file mode 100644
index 9dc2c9fdba..0000000000
--- a/packages/app/src/components/sidebar/pinned-section-header.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useMemo } from "react";
-import { useTranslation } from "react-i18next";
-import { ChevronDown, ChevronRight } from "lucide-react-native";
-import { Pressable, Text } from "react-native";
-import { StyleSheet, withUnistyles } from "react-native-unistyles";
-import { useIsCompactFormFactor } from "@/constants/layout";
-import { isNative } from "@/constants/platform";
-import type { Theme } from "@/styles/theme";
-
-const ThemedChevronDown = withUnistyles(ChevronDown);
-const ThemedChevronRight = withUnistyles(ChevronRight);
-const foregroundMutedColorMapping = (theme: Theme) => ({
- color: theme.colors.foregroundMuted,
-});
-
-export function PinnedSectionHeader({
- collapsed,
- onToggle,
-}: {
- collapsed: boolean;
- onToggle: () => void;
-}) {
- const { t } = useTranslation();
- const isCompact = useIsCompactFormFactor();
- const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
- const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
-
- return (
-
- {({ hovered }) => (
- <>
- {t("sidebar.pinned.title")}
- {hovered || isNative || isCompact ? (
-
- ) : null}
- >
- )}
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- header: {
- minHeight: 36,
- flexDirection: "row",
- alignItems: "center",
- alignSelf: "flex-start",
- gap: theme.spacing[1],
- paddingHorizontal: theme.spacing[2],
- paddingVertical: theme.spacing[1],
- userSelect: "none",
- },
- title: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.normal,
- },
-}));
diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx
index c3950efe2d..1830e3fa10 100644
--- a/packages/app/src/components/sidebar/project-leading-visual.tsx
+++ b/packages/app/src/components/sidebar/project-leading-visual.tsx
@@ -1,9 +1,11 @@
import { ActivityIndicator, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
-import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
-import { ICON_SIZE, type Theme } from "@/styles/theme";
+import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
+import { densityIconBox, densityIconProps } from "@/styles/density";
+import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -22,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
+// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
+// workspaceBranchText) so the icon centers on the title rather than floating above it.
+const LEADING_SLOT_HEIGHT = 20;
const STATUS_BADGE_SIZE = 12;
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
@@ -34,9 +39,8 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
-// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
-// workspaceBranchText) so the icon centers on the title rather than floating above it.
-const LEADING_SLOT_HEIGHT = 20;
+const DensityProjectIconView = withUnistyles(ProjectIconView);
+const projectIconProps = densityIconProps("md", () => ({}));
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
@@ -170,6 +174,7 @@ function ProjectStatusBadge({
statusBucket: SidebarStateBucket;
backdrop: SidebarSurfaceBackdrop;
}) {
+ const { t } = useTranslation();
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
// so nesting it inside would clip it against the very thing that was meant to separate it from
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
@@ -177,7 +182,7 @@ function ProjectStatusBadge({
return (
@@ -188,7 +193,7 @@ function ProjectStatusBadge({
return (
@@ -228,12 +233,12 @@ function ProjectIcon({
projectViewKey: string;
}) {
return (
-
);
}
@@ -269,11 +274,11 @@ const styles = StyleSheet.create((theme) => {
return {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
- // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
- // 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
- // which is why the workspace status indicator is also 20 tall. Keep the two in step.
+ // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
+ // only as tall as the icon puts it above the title and the kebab — which is why the
+ // workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
projectLeadingVisualSlot: {
- width: theme.iconSize.md,
+ width: densityIconBox("md"),
height: LEADING_SLOT_HEIGHT,
flexShrink: 0,
alignItems: "center",
@@ -282,8 +287,8 @@ const styles = StyleSheet.create((theme) => {
// Anchors the corner badge to the icon rather than to the taller slot.
projectIconBox: {
position: "relative",
- width: theme.iconSize.md,
- height: theme.iconSize.md,
+ width: densityIconBox("md"),
+ height: densityIconBox("md"),
},
projectIconFallbackText: {
fontSize: 9,
diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx
index f67bd738ba..01e07eedd4 100644
--- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx
+++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx
@@ -2,19 +2,11 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-react-native";
-import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
-import type { Theme } from "@/styles/theme";
-
-const foregroundMutedColorMapping = (theme: Theme) => ({
- color: theme.colors.foregroundMuted,
-});
-const foregroundColorMapping = (theme: Theme) => ({
- color: theme.colors.foreground,
-});
-const ThemedChevronDown = withUnistyles(ChevronDown);
-const ThemedChevronUp = withUnistyles(ChevronUp);
+import { DensityIcon } from "@/components/ui/density-icon";
+import { density, densityIconBox } from "@/styles/density";
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({
{({ hovered, pressed }) => (
<>
- {expanded ? (
-
- ) : (
-
- )}
+
{label}
@@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({
const styles = StyleSheet.create((theme) => ({
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
row: {
- minHeight: 36,
+ minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -106,8 +92,8 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it rather than two points to their left.
iconSlot: {
- width: theme.iconSize.md,
- height: theme.iconSize.md,
+ width: densityIconBox("md"),
+ height: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx
index 4519f6d4ff..2eeded947d 100644
--- a/packages/app/src/components/sidebar/sidebar-header-row.tsx
+++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx
@@ -1,22 +1,12 @@
-import {
- useCallback,
- useMemo,
- useState,
- type ComponentType,
- type ReactNode,
- type Ref,
-} from "react";
+import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
import { Pressable, Text, View } from "react-native";
-import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
-import { ICON_SIZE } from "@/styles/theme";
-import type { Theme } from "@/styles/theme";
+import { DensityIcon } from "@/components/ui/density-icon";
import { Shortcut } from "@/components/ui/shortcut";
+import { density, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
-const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
-const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
@@ -49,7 +39,7 @@ interface SidebarHeaderRowProps {
}
export function SidebarHeaderRow({
- icon: Icon,
+ icon,
label,
onPress,
isActive = false,
@@ -64,9 +54,8 @@ export function SidebarHeaderRow({
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
- const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
const isHighlighted = isHovered || isActive;
- const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
+ const iconSize = variant === "header" ? "md" : "sm";
let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) {
@@ -91,10 +80,11 @@ export function SidebarHeaderRow({
aria-selected={isActive}
style={styles.button}
>
- {ThemedIcon ? (
-
) : (
@@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
- minHeight: 28,
+ minHeight: density.tightRowHeight,
borderRadius: theme.borderRadius.lg,
},
rowHighlighted: {
@@ -152,14 +142,14 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
- minHeight: 28,
+ minHeight: density.tightRowHeight,
paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the list below.
paddingHorizontal: theme.spacing[2],
},
- iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
- iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
+ iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
+ iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
label: {
flexShrink: 1,
fontSize: theme.fontSize.base,
diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx
index 5f603392f2..97ff3d2772 100644
--- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx
+++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx
@@ -14,14 +14,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { buttonControlHeight } from "@/components/ui/control-geometry";
-import { useIsCompactFormFactor } from "@/constants/layout";
+import { DensityIcon } from "@/components/ui/density-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
+import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -32,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const ThemedActivity = withUnistyles(Activity);
-const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedGift = withUnistyles(Gift);
const ThemedKeyboard = withUnistyles(Keyboard);
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
-const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
- const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -106,15 +103,16 @@ export function SidebarHelpMenu() {
{({ hovered }) => (
-
)}
@@ -184,14 +182,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
- trigger: (isCompact: boolean) => ({
- width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
- height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
+ trigger: {
+ width: density.iconButton,
+ height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
- }),
+ },
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx
index 9399a3bd13..ec834aee19 100644
--- a/packages/app/src/components/sidebar/sidebar-model.tsx
+++ b/packages/app/src/components/sidebar/sidebar-model.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
@@ -54,7 +55,9 @@ export function SidebarModelProvider({
active?: boolean;
children: ReactNode;
}) {
+ const { t } = useTranslation();
const list = useSidebarWorkspacesList({ enabled: active });
+ const showBackground = useSidebarViewStore((state) => state.showBackground);
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
@@ -105,9 +108,12 @@ export function SidebarModelProvider({
workspaces: [...workspaceEntriesByKey.values()],
projectFilters: resolvedProjectFilters,
});
- const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
+ const filtered = filterWorkspacesByLabels({
+ workspaces: byProject.filter((workspace) => showBackground || !workspace.background),
+ ...labelFilter,
+ });
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
- }, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
+ }, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
const visibleWorkspaceKeys = useMemo(
() => new Set(filteredWorkspaceEntriesByKey.keys()),
[filteredWorkspaceEntriesByKey],
@@ -122,16 +128,19 @@ export function SidebarModelProvider({
const included = new Set(resolvedProjectFilters);
projects = projects.filter((project) => included.has(project.viewKey));
}
- if (hasActiveLabelFilter) {
+ if (hasActiveLabelFilter || !showBackground) {
projects = projects.flatMap((project) => {
const workspaces = project.workspaces.filter((workspace) =>
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
- return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
+ return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
+ ? [{ ...project, workspaces }]
+ : [];
});
}
return projects;
}, [
+ showBackground,
hasActiveLabelFilter,
hasActiveProjectFilter,
resolvedProjectFilters,
@@ -150,6 +159,7 @@ export function SidebarModelProvider({
pinnedCollapsed,
collapsedProjectKeys,
collapsedWorkspaceGroupKeys,
+ t,
}),
[
collapsedProjectKeys,
@@ -161,6 +171,7 @@ export function SidebarModelProvider({
pinnedKeys,
pinnedWorkspaceOrder,
filteredWorkspaceEntriesByKey,
+ t,
],
);
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx
index ec13605d08..e57f3cf6ac 100644
--- a/packages/app/src/components/sidebar/sidebar-popover.tsx
+++ b/packages/app/src/components/sidebar/sidebar-popover.tsx
@@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model";
/**
* The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on
- * compact ones. Plugin items and the built-in Usage item open it the same way:
+ * compact ones. Plugin items open it this way:
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
* and `SidebarPopoverSurface` for the body.
*/
diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts
index 55882a0c49..c2dc31f2ee 100644
--- a/packages/app/src/components/sidebar/sidebar-projection.test.ts
+++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts
@@ -1,3 +1,4 @@
+import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
@@ -85,6 +86,7 @@ function projectionInput(options?: {
pinnedCollapsed: options?.pinnedCollapsed ?? false,
collapsedProjectKeys: new Set(),
collapsedWorkspaceGroupKeys: new Set(),
+ t: i18n.t,
};
}
diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts
index c67042b45e..2e84f02aae 100644
--- a/packages/app/src/components/sidebar/sidebar-projection.ts
+++ b/packages/app/src/components/sidebar/sidebar-projection.ts
@@ -1,3 +1,4 @@
+import type { TFunction } from "i18next";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import {
splitPinnedSidebarGroups,
@@ -45,6 +46,7 @@ export interface SidebarProjectionInput {
pinnedCollapsed: boolean;
collapsedProjectKeys: ReadonlySet;
collapsedWorkspaceGroupKeys: ReadonlySet;
+ t: TFunction;
}
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
@@ -100,7 +102,7 @@ function buildWorkspaceGroups(
return [];
case "status":
return statusWorkspaceGroups(
- buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey),
+ buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t),
);
}
}
diff --git a/packages/app/src/components/sidebar/sidebar-section-header.tsx b/packages/app/src/components/sidebar/sidebar-section-header.tsx
new file mode 100644
index 0000000000..7a959435bc
--- /dev/null
+++ b/packages/app/src/components/sidebar/sidebar-section-header.tsx
@@ -0,0 +1,115 @@
+import { useMemo, type ReactNode } from "react";
+import { ChevronDown, ChevronRight } from "lucide-react-native";
+import { Pressable, Text, View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { DensityIcon } from "@/components/ui/density-icon";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import { isNative } from "@/constants/platform";
+import { density } from "@/styles/density";
+
+/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle`
+ * collapses from its title; children sit at the trailing edge as its actions. */
+export function SidebarSectionHeader({
+ title,
+ collapsed,
+ onToggle,
+ testID,
+ children,
+}: {
+ title: string;
+ collapsed?: boolean;
+ onToggle?: () => void;
+ testID?: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ {onToggle ? (
+
+ ) : (
+
+ {title}
+
+ )}
+ {children ? {children} : null}
+
+ );
+}
+
+function CollapsibleTitle({
+ title,
+ collapsed,
+ onToggle,
+ testID,
+}: {
+ title: string;
+ collapsed: boolean;
+ onToggle: () => void;
+ testID?: string;
+}) {
+ const isCompact = useIsCompactFormFactor();
+ const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
+
+ return (
+
+ {({ hovered }) => (
+ <>
+ {title}
+ {hovered || isNative || isCompact ? (
+
+ ) : null}
+ >
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ header: {
+ minHeight: density.rowHeight,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: theme.spacing[2],
+ // The title lands at spacing[2] to align with the rows' leading icons. Trailing actions
+ // are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink
+ // rather than the box to the row rail.
+ paddingLeft: theme.spacing[2],
+ paddingRight: 4,
+ userSelect: "none",
+ },
+ // The toggle reaches back over the header's leading padding so the whole label edge is pressable.
+ toggle: {
+ alignSelf: "stretch",
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ marginLeft: -theme.spacing[2],
+ paddingHorizontal: theme.spacing[2],
+ },
+ title: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.normal,
+ },
+ actions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ },
+}));
diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx
index 051121fd3f..baffc3a010 100644
--- a/packages/app/src/components/sidebar/sidebar-status-list.tsx
+++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
-import type { Theme } from "@/styles/theme";
+import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -73,7 +73,7 @@ import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
-import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
+import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
-const foregroundMutedColorMapping = (theme: Theme) => ({
+const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
color: theme.colors.foregroundMuted,
-});
+}));
// One mapping per bucket, resolved through the status-dot producer so a group header and
// the rows under it cannot disagree about what "failed" looks like.
-const needsInputColorMapping = (theme: Theme) => ({
+const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
-});
-const failedColorMapping = (theme: Theme) => ({
+}));
+const failedGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
-});
-const attentionColorMapping = (theme: Theme) => ({
+}));
+const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
-});
-const runningColorMapping = (theme: Theme) => ({
+}));
+const runningGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
-});
+}));
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({
parentGestureRef,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
+ const { t } = useTranslation();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({
<>
{pinnedWorkspaces.length > 0 ? (
-
+
{pinnedCollapsed ? null : (
<>
state.toggleWorkspaceGroupCollapsed,
@@ -431,7 +438,7 @@ function StatusGroupHeader({
;
}
if (collapsed) {
- return ;
+ return ;
}
- return ;
+ return ;
}
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
switch (bucket) {
case "needs_input":
- return ;
+ return ;
case "failed":
- return ;
+ return ;
case "attention":
- return ;
+ return ;
case "running":
- return ;
+ return ;
case "done":
- return ;
+ return ;
}
}
@@ -1057,7 +1064,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusWorkspaceListContainer: {},
statusGroupRow: {
- minHeight: 36,
+ minHeight: density.rowHeight,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -1083,8 +1090,8 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupLeadingVisualSlot: {
position: "relative",
- width: theme.iconSize.md,
- height: theme.iconSize.md,
+ width: densityIconBox("md"),
+ height: densityIconBox("md"),
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -1107,7 +1114,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
- minHeight: 36,
+ minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx
index 115003a3f2..e1543fac41 100644
--- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx
+++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx
@@ -380,6 +380,7 @@ export function SidebarWorkspaceContextMenu({
serviceLabel: serviceSummary
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
: null,
+ t,
});
const workspaceTarget = useMemo(
() => ({
diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx
index 7b48f0dd6c..7fc904f3b3 100644
--- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx
+++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx
@@ -16,6 +16,7 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
+import { densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -27,19 +28,16 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
+import { DensityIcon } from "@/components/ui/density-icon";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
-const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
});
const ThemedCircleAlert = withUnistyles(CircleAlert);
-const ThemedMonitor = withUnistyles(Monitor);
-const ThemedFolder = withUnistyles(Folder);
-const ThemedFolderGit2 = withUnistyles(FolderGit2);
export function SidebarWorkspaceRowFrame({
workspace,
@@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({
) : null;
}
- let KindIcon: typeof ThemedMonitor;
- if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
- else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
- else KindIcon = ThemedFolder;
+ let KindIcon: typeof Monitor;
+ if (workspaceKind === "local_checkout") KindIcon = Monitor;
+ else if (workspaceKind === "worktree") KindIcon = FolderGit2;
+ else KindIcon = Folder;
const dotColorStyle = getStatusDotColorStyle(bucket);
return (
-
+
{dotColorStyle ? : null}
);
@@ -455,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({
);
}
+// The status dot centres on the title's first line, so both share its line height.
+const TITLE_LINE_HEIGHT = 20;
+
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
@@ -482,8 +483,8 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
- width: theme.iconSize.md,
- height: 20,
+ width: densityIconBox("md"),
+ height: TITLE_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -517,7 +518,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: "400",
- lineHeight: 20,
+ lineHeight: TITLE_LINE_HEIGHT,
opacity: 0.76,
flex: 1,
minWidth: 0,
diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx
index f8dcdebaf5..c5f8a278bd 100644
--- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx
+++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx
@@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
+import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
- minHeight: 36,
+ minHeight: density.rowHeight,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts
index 2068dbe271..465caf1344 100644
--- a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts
+++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts
@@ -1,3 +1,4 @@
+import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import {
resolveSidebarWorkspaceAccessibilityLabel,
@@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
workspaceTitleSource: "title",
hostBadgeLabel: "Build host",
+ t: i18n.t,
});
expect(label).toBe("Investigate search, Build host");
@@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
hostBadgeLabel: "Build host",
pullRequestLabel: "Pull request 42",
serviceLabel: "Service web running",
+ t: i18n.t,
});
expect(label).toBe(
@@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
workspaceTitleSource: "title",
leadingProjectName: "Search project",
hostBadgeLabel: "Build host",
+ t: i18n.t,
});
expect(label).toBe("Search project, Investigate search, Build host");
diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.ts
index 955c9d2636..da3782f082 100644
--- a/packages/app/src/components/sidebar/sidebar-workspace-title.ts
+++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts
@@ -1,6 +1,7 @@
+import type { TFunction } from "i18next";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
-import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
+import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
export function resolveSidebarWorkspacePrimaryLabel(input: {
workspace: Pick;
@@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
hostBadgeLabel?: string | null;
pullRequestLabel?: string | null;
serviceLabel?: string | null;
+ t: TFunction;
}): string {
return [
input.leadingProjectName,
@@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
input.serviceLabel,
input.workspace.statusBucket === "done"
? null
- : STATUS_BUCKET_LABELS[input.workspace.statusBucket],
+ : getStatusBucketLabel(input.workspace.statusBucket, input.t),
]
.filter((label): label is string => Boolean(label))
.join(", ");
diff --git a/packages/app/src/components/ui/autocomplete-utils.test.ts b/packages/app/src/components/ui/autocomplete-utils.test.ts
index dd67c23b57..ecf046a8aa 100644
--- a/packages/app/src/components/ui/autocomplete-utils.test.ts
+++ b/packages/app/src/components/ui/autocomplete-utils.test.ts
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
-import {
- getAutocompleteFallbackIndex,
- getAutocompleteScrollOffset,
- orderAutocompleteOptions,
-} from "./autocomplete-utils";
+import { getAutocompleteFallbackIndex, orderAutocompleteOptions } from "./autocomplete-utils";
const OPTIONS = ["alpha", "beta", "gamma"];
@@ -28,27 +24,3 @@ describe("getAutocompleteFallbackIndex", () => {
expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0);
});
});
-
-describe("getAutocompleteScrollOffset", () => {
- it("scrolls up when the active item is above the viewport", () => {
- expect(
- getAutocompleteScrollOffset({
- currentOffset: 120,
- viewportHeight: 80,
- itemTop: 90,
- itemHeight: 20,
- }),
- ).toBe(90);
- });
-
- it("scrolls down when the active item is below the viewport", () => {
- expect(
- getAutocompleteScrollOffset({
- currentOffset: 0,
- viewportHeight: 100,
- itemTop: 150,
- itemHeight: 24,
- }),
- ).toBe(74);
- });
-});
diff --git a/packages/app/src/components/ui/autocomplete-utils.ts b/packages/app/src/components/ui/autocomplete-utils.ts
index f8250856df..175cf6b62d 100644
--- a/packages/app/src/components/ui/autocomplete-utils.ts
+++ b/packages/app/src/components/ui/autocomplete-utils.ts
@@ -29,28 +29,3 @@ export function getAutocompleteNextIndex(args: {
}): number {
return getNextActiveIndex(args);
}
-
-export function getAutocompleteScrollOffset(args: {
- currentOffset: number;
- viewportHeight: number;
- itemTop: number;
- itemHeight: number;
-}): number {
- if (args.viewportHeight <= 0) {
- return args.currentOffset;
- }
-
- const itemBottom = args.itemTop + args.itemHeight;
- const viewportTop = args.currentOffset;
- const viewportBottom = args.currentOffset + args.viewportHeight;
-
- if (args.itemTop < viewportTop) {
- return Math.max(0, args.itemTop);
- }
-
- if (itemBottom > viewportBottom) {
- return Math.max(0, itemBottom - args.viewportHeight);
- }
-
- return args.currentOffset;
-}
diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx
index cc684f8b41..7b5b99b316 100644
--- a/packages/app/src/components/ui/autocomplete.tsx
+++ b/packages/app/src/components/ui/autocomplete.tsx
@@ -13,7 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
-import { getAutocompleteScrollOffset } from "./autocomplete-utils";
+import { getScrollOffsetToRevealItem } from "./scroll-reveal";
export interface AutocompleteOption {
id: string;
@@ -146,7 +146,7 @@ export function Autocomplete({
return;
}
- const nextOffset = getAutocompleteScrollOffset({
+ const nextOffset = getScrollOffsetToRevealItem({
currentOffset: scrollOffsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx
index e160970223..707ce60648 100644
--- a/packages/app/src/components/ui/button.tsx
+++ b/packages/app/src/components/ui/button.tsx
@@ -174,6 +174,7 @@ export function Button({
...props
}: PropsWithChildren<
Omit & {
+ ref?: React.Ref;
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: LeftIcon;
diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts
index 6129af9463..e3e98633b3 100644
--- a/packages/app/src/components/ui/combobox-options.test.ts
+++ b/packages/app/src/components/ui/combobox-options.test.ts
@@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => {
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});
+
+describe("search-only option text", () => {
+ it("finds hidden search text while keeping title matches first", () => {
+ const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" };
+ const titleMatch = { id: "pr:8", label: "2024 release" };
+ expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([
+ titleMatch,
+ bodyMatch,
+ ]);
+ expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]);
+ expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]);
+ });
+});
diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts
index e23f2315ef..006eb1a21e 100644
--- a/packages/app/src/components/ui/combobox-options.ts
+++ b/packages/app/src/components/ui/combobox-options.ts
@@ -10,18 +10,22 @@ export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
+ /** Additional text to match without displaying it in the option row. */
+ searchText?: string;
kind?: ComboboxOptionKind;
}
-const DESCRIPTION_FALLBACK_TIER = 99;
+const SECONDARY_TEXT_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
- if (!opt.description) return null;
- const descriptionScore = scoreTextFields(search, [opt.description]);
- if (!descriptionScore) return null;
- return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
+ const secondaryFields = opt.searchText
+ ? [opt.label, opt.id, opt.description ?? "", opt.searchText]
+ : [opt.description ?? ""];
+ const secondaryScore = scoreTextFields(search, secondaryFields);
+ if (!secondaryScore) return null;
+ return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
}
export interface BuildVisibleComboboxOptionsInput {
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 736ca3bfa5..caf73d1bbc 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
-import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
+import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native";
@@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
+ SheetVisibleFrame,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
@@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
+ const { footer, safeAreaBottom } = props;
const frameStyle = useMemo(
- () => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
- [props.safeAreaBottom],
+ () => [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 ? (
@@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
-
- {props.header ? (
-
- ) : (
- <>
-
-
- {props.title}
-
-
- {props.stickyHeader}
- {!props.hasChildren && props.searchable ? (
-
- ) : null}
- >
- )}
- {props.hasChildren && !props.mobileChildrenScrollEnabled ? (
- body
- ) : (
-
- {body}
-
- )}
- {props.footer ? {props.footer} : null}
-
+
+
+ {props.header ? (
+
+ ) : (
+ <>
+
+
+ {props.title}
+
+
+ {props.stickyHeader}
+ {!props.hasChildren && props.searchable ? (
+
+ ) : null}
+ >
+ )}
+ {props.hasChildren && !props.mobileChildrenScrollEnabled ? (
+ body
+ ) : (
+
+ {body}
+
+ )}
+
+
);
}
diff --git a/packages/app/src/components/ui/density-icon.tsx b/packages/app/src/components/ui/density-icon.tsx
new file mode 100644
index 0000000000..ee24ee3588
--- /dev/null
+++ b/packages/app/src/components/ui/density-icon.tsx
@@ -0,0 +1,51 @@
+import { useMemo, type ComponentType } from "react";
+import { withUnistyles } from "react-native-unistyles";
+import { densityIconSize, type DensityIconSize } from "@/styles/density";
+import type { Theme } from "@/styles/theme";
+
+type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>;
+
+type IconColor = {
+ [K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never;
+}[keyof Theme["colors"]];
+
+function IconGlyph({
+ icon: Icon,
+ size,
+ color,
+ strokeWidth,
+}: {
+ icon: IconComponent;
+ size: number;
+ color: string;
+ strokeWidth?: number;
+}) {
+ return ;
+}
+
+const ThemedIconGlyph = withUnistyles(IconGlyph);
+
+/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */
+export function DensityIcon({
+ icon,
+ size,
+ sizeAdjustment = 0,
+ color,
+ strokeWidth,
+}: {
+ icon: IconComponent;
+ size: DensityIconSize;
+ /** Only for a glyph that reads larger than others at the same size. */
+ sizeAdjustment?: number;
+ color: IconColor;
+ strokeWidth?: number;
+}) {
+ const uniProps = useMemo(
+ () => (theme: Theme, rt: { breakpoint?: string }) => ({
+ size: densityIconSize(rt, size) + sizeAdjustment,
+ color: theme.colors[color],
+ }),
+ [size, sizeAdjustment, color],
+ );
+ return ;
+}
diff --git a/packages/app/src/components/ui/edge-fade.tsx b/packages/app/src/components/ui/edge-fade.tsx
new file mode 100644
index 0000000000..de404aeed8
--- /dev/null
+++ b/packages/app/src/components/ui/edge-fade.tsx
@@ -0,0 +1,72 @@
+import { useId } from "react";
+import { View, type StyleProp, type ViewStyle } from "react-native";
+import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
+import { withUnistyles } from "react-native-unistyles";
+import type { Theme } from "@/styles/theme";
+
+type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
+
+const backdropColorMappings: Record { color: string }> = {
+ surface0: (theme) => ({ color: theme.colors.surface0 }),
+ surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
+};
+
+interface EdgeFadeSvgProps {
+ gradientId: string;
+ color: string;
+ edge: "top" | "bottom";
+ solidFraction: number;
+}
+
+function EdgeFadeSvg({ gradientId, color, edge, solidFraction }: EdgeFadeSvgProps) {
+ const fromTop = edge === "top";
+ return (
+
+
+
+ {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
+ some engines and leaves a grey fringe. Keep it to three stops: Android rendered a
+ four-stop version far more transparent than its stops specify. */}
+
+
+
+
+
+
+
+ );
+}
+
+const ThemedEdgeFadeSvg = withUnistyles(EdgeFadeSvg);
+
+interface EdgeFadeProps {
+ /** The solid edge; content dissolves moving away from it. */
+ edge: "top" | "bottom";
+ /** Share of the fade, measured from the solid edge, that stays fully opaque. */
+ solidFraction: number;
+ backdrop: EdgeFadeBackdrop;
+ /** Position and size. Keep the fade inside its parent: Android clips overhanging children. */
+ style: StyleProp;
+}
+
+/** Dissolves content into the surface behind chrome that floats over it. */
+export function EdgeFade({ edge, solidFraction, backdrop, style }: EdgeFadeProps) {
+ // React-generated ids contain characters that are invalid inside SVG fragment references.
+ const gradientId = `edge-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
+ return (
+
+
+
+ );
+}
diff --git a/packages/app/src/components/ui/floating-action-button.tsx b/packages/app/src/components/ui/floating-action-button.tsx
index 33d72985fe..2b06e097f2 100644
--- a/packages/app/src/components/ui/floating-action-button.tsx
+++ b/packages/app/src/components/ui/floating-action-button.tsx
@@ -1,47 +1,67 @@
+import { forwardRef, useMemo } from "react";
+import { Text, type View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
-import { ICON_SIZE, SPACING } from "@/styles/theme";
+import { ICON_SIZE } from "@/styles/theme";
-const FLOATING_ACTION_BUTTON_SIZE = 56;
-const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
-
-/** Space a scrolling surface keeps below its content so the button traps nothing. */
-export const FLOATING_ACTION_BUTTON_CLEARANCE =
- FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
+import {
+ FLOATING_ACTION_BUTTON_SIZE,
+ FLOATING_ACTION_BUTTON_INSET,
+} from "./floating-action-layout";
+export {
+ FLOATING_ACTION_BUTTON_CLEARANCE,
+ floatingActionsClearance,
+} from "./floating-action-layout";
export interface FloatingActionButtonProps {
icon: LucideIcon;
- /** Names the action; the button carries no label. */
+ /** Names the action for assistive technology. */
accessibilityLabel: string;
onPress: () => void;
+ label?: string;
+ disabled?: boolean;
+ bottomInset?: number;
testID?: string;
}
/**
- * The one floating action a scrolling surface can offer. It pins itself to the
- * bottom-right of its nearest positioned ancestor, so the ancestor decides what
- * the button may cover.
+ * Pins an action to the bottom-right of its nearest positioned ancestor.
+ * The surface reserves scroll clearance and uses bottomInset to separate stacked actions.
*/
-export function FloatingActionButton({
- icon: Icon,
- accessibilityLabel,
- onPress,
- testID,
-}: FloatingActionButtonProps) {
- return (
-
-
-
- );
-}
+export const FloatingActionButton = forwardRef(
+ function FloatingActionButton(
+ { icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID },
+ ref,
+ ) {
+ const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
+ const buttonStyle = useMemo(
+ () => [
+ styles.button,
+ label && styles.labeled,
+ disabled && styles.disabled,
+ { bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset },
+ ],
+ [bottomInset, disabled, label],
+ );
+ return (
+
+
+ {label ? {label} : null}
+
+ );
+ },
+);
const styles = StyleSheet.create((theme) => ({
button: {
@@ -56,6 +76,15 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
...theme.shadow.md,
},
+ labeled: {
+ width: "auto",
+ minWidth: FLOATING_ACTION_BUTTON_SIZE,
+ paddingHorizontal: theme.spacing[4],
+ flexDirection: "row",
+ gap: theme.spacing[2],
+ },
+ label: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
+ disabled: { opacity: theme.opacity[50] },
pressed: {
backgroundColor: theme.colors.interactionHighlight,
},
diff --git a/packages/app/src/components/ui/floating-action-layout.test.ts b/packages/app/src/components/ui/floating-action-layout.test.ts
new file mode 100644
index 0000000000..2256f4ef9b
--- /dev/null
+++ b/packages/app/src/components/ui/floating-action-layout.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import {
+ floatingActionsClearance,
+ FLOATING_ACTION_BUTTON_CLEARANCE,
+} from "./floating-action-layout";
+
+describe("floating action scroll clearance", () => {
+ it("keeps the last diff line above Jump to file before and after feedback is added", () => {
+ const jump = FLOATING_ACTION_BUTTON_CLEARANCE;
+ const feedback = FLOATING_ACTION_BUTTON_CLEARANCE + 44;
+ expect(floatingActionsClearance([jump, 0], 34)).toBe(106);
+ expect(floatingActionsClearance([jump, feedback], 34)).toBe(222);
+ expect(floatingActionsClearance([0, feedback], 34)).toBe(150);
+ expect(floatingActionsClearance([jump, feedback], 0)).toBe(188);
+ expect(floatingActionsClearance([0, 0], 34)).toBe(0);
+ });
+});
diff --git a/packages/app/src/components/ui/floating-action-layout.ts b/packages/app/src/components/ui/floating-action-layout.ts
new file mode 100644
index 0000000000..c54fd62dbe
--- /dev/null
+++ b/packages/app/src/components/ui/floating-action-layout.ts
@@ -0,0 +1,15 @@
+import { SPACING } from "@/styles/theme";
+
+export const FLOATING_ACTION_BUTTON_SIZE = 56;
+export const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
+export const FLOATING_ACTION_BUTTON_CLEARANCE =
+ FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
+
+/** Reserve the stacked actions and the safe-area inset once for their scrolling surface. */
+export function floatingActionsClearance(
+ actionClearances: readonly number[],
+ bottomInset: number,
+): number {
+ const clearance = actionClearances.reduce((total, action) => total + action, 0);
+ return clearance > 0 ? clearance + bottomInset : 0;
+}
diff --git a/packages/app/src/components/ui/icon-button-chrome.ts b/packages/app/src/components/ui/icon-button-chrome.ts
index 394a744e64..75735ac986 100644
--- a/packages/app/src/components/ui/icon-button-chrome.ts
+++ b/packages/app/src/components/ui/icon-button-chrome.ts
@@ -1,3 +1,4 @@
+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";
@@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
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;
export interface IconButtonChromeState {
@@ -27,20 +30,53 @@ interface IconButtonChromeOptions {
state?: IconButtonChromeState;
compact?: boolean;
disabled?: boolean;
+ /** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
+ round?: boolean;
style?: StyleProp;
}
+const IconButtonChromeContext = createContext<"default" | "header-island">("default");
+
+/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */
+export const IconButtonChromeProvider = IconButtonChromeContext.Provider;
+
+/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */
+export function useIconButtonChromeStyle({
+ size,
+ compact,
+ disabled,
+ round,
+ style,
+ active,
+}: Omit & { active?: boolean }) {
+ const scope = useContext(IconButtonChromeContext);
+ return useCallback(
+ (state: IconButtonChromeState) =>
+ iconButtonChromeStyle({
+ size: scope === "header-island" ? "large" : size,
+ compact,
+ disabled,
+ round: scope === "header-island" || round,
+ style,
+ state: { ...state, active: active || state.active },
+ }),
+ [scope, size, compact, disabled, round, style, active],
+ );
+}
+
/** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
export function iconButtonChromeStyle({
size,
state,
compact = false,
disabled = false,
+ round = false,
style,
}: IconButtonChromeOptions): StyleProp {
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
return [
resolveIconButtonFrame(size, compact),
+ round ? styles.round : null,
style,
highlighted ? styles.highlighted : null,
disabled ? styles.disabled : null,
@@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
const styles = StyleSheet.create((theme) => ({
large: {
width: {
- xs: 32,
+ xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
height: {
- xs: 32,
+ xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
@@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
+ round: {
+ borderRadius: theme.borderRadius.full,
+ },
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx
new file mode 100644
index 0000000000..d107547dcf
--- /dev/null
+++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx
@@ -0,0 +1,91 @@
+import {
+ BottomSheetModalInternalProvider,
+ INITIAL_CONTAINER_LAYOUT,
+ useBottomSheetModalInternal,
+} from "@gorhom/bottom-sheet";
+import { PortalHost } from "@gorhom/portal";
+import { useCallback, useId, useMemo, useRef, type ReactNode } from "react";
+import {
+ Dimensions,
+ StatusBar,
+ StyleSheet,
+ View,
+ type LayoutChangeEvent,
+ type StyleProp,
+ type ViewStyle,
+} from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+
+/**
+ * Hosts the sheets presented from inside it.
+ *
+ * A sheet's content normally lands at the app root, on the far side of the portal, where every
+ * context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a
+ * scope, a sheet renders into the scope's own host instead, so its content reads the scope's
+ * contexts — the navigator, the workspace providers — without bridging each one by hand. The
+ * scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking,
+ * Back and the backdrop treat it like every other sheet.
+ *
+ * The host fills the scope, and the sheets presented in it size and dock to the scope, not the
+ * window. Sheets presented outside the scope keep drawing at the root, above it.
+ */
+export function SheetHostScope({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp;
+}) {
+ const parent = useBottomSheetModalInternal();
+ const hostName = `sheet-host-${useId()}`;
+ const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT });
+ const hostRef = useRef(null);
+
+ // Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against
+ // the scope the way a root sheet lays out against the window.
+ const handleLayout = useCallback(
+ ({ nativeEvent }: LayoutChangeEvent) => {
+ const { height } = nativeEvent.layout;
+ hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
+ const windowHeight = Dimensions.get("window").height;
+ const top = pageY ?? 0;
+ containerLayoutState.set({
+ height,
+ offset: {
+ top,
+ bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))),
+ left: 0,
+ right: 0,
+ },
+ });
+ });
+ },
+ [containerLayoutState],
+ );
+
+ const value = useMemo(
+ () => ({ ...parent, hostName, containerLayoutState }),
+ [containerLayoutState, hostName, parent],
+ );
+
+ return (
+